- 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.
Unified · Split
| @@ -43,10 +43,12 @@ func (e *ClaudeExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, r · 42 unmodified lines | ||
| 43 | 43 | if baseURL == "" { |
| 44 | 44 | baseURL = "https://api.anthropic.com" |
| 45 | 45 | } |
| 46 | + reporter := newUsageReporter(ctx, e.Identifier(), req.Model, auth) | |
| 46 | 47 | from := opts.SourceFormat |
| 47 | 48 | to := sdktranslator.FromString("claude") |
| 48 | 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 | 53 | if !strings.HasPrefix(req.Model, "claude-3-5-haiku") { |
| 52 | 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 | 96 | return cliproxyexecutor.Response{}, err |
| 95 | 97 | } |
| 96 | 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 | 109 | var param any |
| 98 | 110 | out := sdktranslator.TranslateNonStream(ctx, to, from, req.Model, bytes.Clone(opts.OriginalRequest), body, data, ¶m) |
| 99 | 111 | 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 | 119 | if baseURL == "" { |
| 108 | 120 | baseURL = "https://api.anthropic.com" |
| 109 | 121 | } |
| 122 | + reporter := newUsageReporter(ctx, e.Identifier(), req.Model, auth) | |
| 110 | 123 | from := opts.SourceFormat |
| 111 | 124 | to := sdktranslator.FromString("claude") |
| 112 | 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 | 159 | for scanner.Scan() { |
| 147 | 160 | line := scanner.Bytes() |
| 148 | 161 | appendAPIResponseChunk(ctx, e.cfg, line) |
| 162 | + if detail, ok := parseClaudeStreamUsage(line); ok { | |
| 163 | + reporter.publish(ctx, detail) | |
| 164 | + } | |
| 149 | 165 | chunks := sdktranslator.TranslateStream(ctx, to, from, req.Model, bytes.Clone(opts.OriginalRequest), body, bytes.Clone(line), ¶m) |
| 150 | 166 | for i := range chunks { |
| 151 | 167 | out <- cliproxyexecutor.StreamChunk{Payload: []byte(chunks[i])} |
| @@ -18,12 +18,15 @@ import ( · 17 unmodified lines | ||
| 18 | 18 | cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/executor" |
| 19 | 19 | sdktranslator "github.com/router-for-me/CLIProxyAPI/v6/sdk/translator" |
| 20 | 20 | log "github.com/sirupsen/logrus" |
| 21 | + "github.com/tidwall/gjson" | |
| 21 | 22 | "github.com/tidwall/sjson" |
| 22 | 23 | |
| 23 | 24 | "github.com/gin-gonic/gin" |
| 24 | 25 | "github.com/google/uuid" |
| 25 | 26 | ) |
| 26 | 27 | |
| 28 | +var dataTag = []byte("data:") | |
| 29 | + | |
| 27 | 30 | // CodexExecutor is a stateless executor for Codex (OpenAI Responses API entrypoint). |
| 28 | 31 | // If api_key is unavailable on auth, it falls back to legacy via ClientAdapter. |
| 29 | 32 | type CodexExecutor struct { |
| @@ -44,6 +47,7 @@ func (e *CodexExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, re · 14 unmodified lines | ||
| 44 | 47 | if baseURL == "" { |
| 45 | 48 | baseURL = "https://chatgpt.com/backend-api/codex" |
| 46 | 49 | } |
| 50 | + reporter := newUsageReporter(ctx, e.Identifier(), req.Model, auth) | |
| 47 | 51 | |
| 48 | 52 | from := opts.SourceFormat |
| 49 | 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 | 84 | url := strings.TrimSuffix(baseURL, "/") + "/responses" |
| 79 | 85 | recordAPIRequest(ctx, e.cfg, body) |
| 80 | 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 | 109 | return cliproxyexecutor.Response{}, err |
| 104 | 110 | } |
| 105 | 111 | appendAPIResponseChunk(ctx, e.cfg, data) |
| 106 | − var param any | |
| 107 | − out := sdktranslator.TranslateNonStream(ctx, to, from, req.Model, bytes.Clone(opts.OriginalRequest), body, data, ¶m) | |
| 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, ¶m) | |
| 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 | |
| 111 | 135 | 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 | ||
| 116 | 140 | if baseURL == "" { |
| 117 | 141 | baseURL = "https://chatgpt.com/backend-api/codex" |
| 118 | 142 | } |
| 143 | + reporter := newUsageReporter(ctx, e.Identifier(), req.Model, auth) | |
| 119 | 144 | |
| 120 | 145 | from := opts.SourceFormat |
| 121 | 146 | to := sdktranslator.FromString("codex") |
| @@ -181,6 +206,16 @@ func (e *CodexExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.Au · 59 unmodified lines | ||
| 181 | 206 | for scanner.Scan() { |
| 182 | 207 | line := scanner.Bytes() |
| 183 | 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 | 219 | chunks := sdktranslator.TranslateStream(ctx, to, from, req.Model, bytes.Clone(opts.OriginalRequest), body, bytes.Clone(line), ¶m) |
| 185 | 220 | for i := range chunks { |
| 186 | 221 | out <- cliproxyexecutor.StreamChunk{Payload: []byte(chunks[i])} |
| @@ -54,6 +54,7 @@ func (e *GeminiCLIExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth · 53 unmodified lines | ||
| 54 | 54 | if err != nil { |
| 55 | 55 | return cliproxyexecutor.Response{}, err |
| 56 | 56 | } |
| 57 | + reporter := newUsageReporter(ctx, e.Identifier(), req.Model, auth) | |
| 57 | 58 | |
| 58 | 59 | from := opts.SourceFormat |
| 59 | 60 | to := sdktranslator.FromString("gemini-cli") |
| @@ -117,6 +118,7 @@ func (e *GeminiCLIExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth · 57 unmodified lines | ||
| 117 | 118 | _ = resp.Body.Close() |
| 118 | 119 | appendAPIResponseChunk(ctx, e.cfg, data) |
| 119 | 120 | if resp.StatusCode >= 200 && resp.StatusCode < 300 { |
| 121 | + reporter.publish(ctx, parseGeminiCLIUsage(data)) | |
| 120 | 122 | var param any |
| 121 | 123 | out := sdktranslator.TranslateNonStream(respCtx, to, from, attemptModel, bytes.Clone(opts.OriginalRequest), payload, data, ¶m) |
| 122 | 124 | return cliproxyexecutor.Response{Payload: []byte(out)}, nil |
| @@ -139,6 +141,7 @@ func (e *GeminiCLIExecutor) ExecuteStream(ctx context.Context, auth *cliproxyaut · 16 unmodified lines | ||
| 139 | 141 | if err != nil { |
| 140 | 142 | return nil, err |
| 141 | 143 | } |
| 144 | + reporter := newUsageReporter(ctx, e.Identifier(), req.Model, auth) | |
| 142 | 145 | |
| 143 | 146 | from := opts.SourceFormat |
| 144 | 147 | to := sdktranslator.FromString("gemini-cli") |
| @@ -215,6 +218,9 @@ func (e *GeminiCLIExecutor) ExecuteStream(ctx context.Context, auth *cliproxyaut · 70 unmodified lines | ||
| 215 | 218 | for scanner.Scan() { |
| 216 | 219 | line := scanner.Bytes() |
| 217 | 220 | appendAPIResponseChunk(ctx, e.cfg, line) |
| 221 | + if detail, ok := parseGeminiCLIStreamUsage(line); ok { | |
| 222 | + reporter.publish(ctx, detail) | |
| 223 | + } | |
| 218 | 224 | if bytes.HasPrefix(line, dataTag) { |
| 219 | 225 | segments := sdktranslator.TranslateStream(respCtx, to, from, attempt, bytes.Clone(opts.OriginalRequest), reqBody, bytes.Clone(line), ¶m) |
| 220 | 226 | for i := range segments { |
| @@ -239,6 +245,7 @@ func (e *GeminiCLIExecutor) ExecuteStream(ctx context.Context, auth *cliproxyaut · 18 unmodified lines | ||
| 239 | 245 | return |
| 240 | 246 | } |
| 241 | 247 | appendAPIResponseChunk(ctx, e.cfg, data) |
| 248 | + reporter.publish(ctx, parseGeminiCLIUsage(data)) | |
| 242 | 249 | var param any |
| 243 | 250 | segments := sdktranslator.TranslateStream(respCtx, to, from, attempt, bytes.Clone(opts.OriginalRequest), reqBody, data, ¶m) |
| 244 | 251 | for i := range segments { |
| @@ -43,6 +43,7 @@ func (e *GeminiExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, r · 42 unmodified lines | ||
| 43 | 43 | // Fallback to legacy client |
| 44 | 44 | return NewClientAdapter("gemini").Execute(ctx, auth, req, opts) |
| 45 | 45 | } |
| 46 | + reporter := newUsageReporter(ctx, e.Identifier(), req.Model, auth) | |
| 46 | 47 | |
| 47 | 48 | // Official Gemini API via API key or OAuth bearer |
| 48 | 49 | from := opts.SourceFormat |
| @@ -92,6 +93,7 @@ func (e *GeminiExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, r · 43 unmodified lines | ||
| 92 | 93 | return cliproxyexecutor.Response{}, err |
| 93 | 94 | } |
| 94 | 95 | appendAPIResponseChunk(ctx, e.cfg, data) |
| 96 | + reporter.publish(ctx, parseGeminiUsage(data)) | |
| 95 | 97 | var param any |
| 96 | 98 | out := sdktranslator.TranslateNonStream(ctx, to, from, req.Model, bytes.Clone(opts.OriginalRequest), body, data, ¶m) |
| 97 | 99 | 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 | 105 | // Fallback to legacy streaming |
| 104 | 106 | return NewClientAdapter("gemini").ExecuteStream(ctx, auth, req, opts) |
| 105 | 107 | } |
| 108 | + reporter := newUsageReporter(ctx, e.Identifier(), req.Model, auth) | |
| 106 | 109 | |
| 107 | 110 | from := opts.SourceFormat |
| 108 | 111 | to := sdktranslator.FromString("gemini") |
| @@ -152,6 +155,9 @@ func (e *GeminiExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.A · 43 unmodified lines | ||
| 152 | 155 | for scanner.Scan() { |
| 153 | 156 | line := scanner.Bytes() |
| 154 | 157 | appendAPIResponseChunk(ctx, e.cfg, line) |
| 158 | + if detail, ok := parseGeminiStreamUsage(line); ok { | |
| 159 | + reporter.publish(ctx, detail) | |
| 160 | + } | |
| 155 | 161 | lines := sdktranslator.TranslateStream(ctx, to, from, req.Model, bytes.Clone(opts.OriginalRequest), body, bytes.Clone(line), ¶m) |
| 156 | 162 | for i := range lines { |
| 157 | 163 | out <- cliproxyexecutor.StreamChunk{Payload: []byte(lines[i])} |
| @@ -38,6 +38,7 @@ func (e *GeminiWebExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth · 37 unmodified lines | ||
| 38 | 38 | if err = state.ensureClient(); err != nil { |
| 39 | 39 | return cliproxyexecutor.Response{}, err |
| 40 | 40 | } |
| 41 | + reporter := newUsageReporter(ctx, e.Identifier(), req.Model, auth) | |
| 41 | 42 | |
| 42 | 43 | mutex := state.getRequestMutex() |
| 43 | 44 | if mutex != nil { |
| @@ -51,6 +52,7 @@ func (e *GeminiWebExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth · 7 unmodified lines | ||
| 51 | 52 | return cliproxyexecutor.Response{}, geminiWebErrorFromMessage(errMsg) |
| 52 | 53 | } |
| 53 | 54 | resp = state.convertToTarget(ctx, req.Model, prep, resp) |
| 55 | + reporter.publish(ctx, parseGeminiUsage(resp)) | |
| 54 | 56 | |
| 55 | 57 | from := opts.SourceFormat |
| 56 | 58 | to := sdktranslator.FromString("gemini-web") |
| @@ -68,6 +70,7 @@ func (e *GeminiWebExecutor) ExecuteStream(ctx context.Context, auth *cliproxyaut · 11 unmodified lines | ||
| 68 | 70 | if err = state.ensureClient(); err != nil { |
| 69 | 71 | return nil, err |
| 70 | 72 | } |
| 73 | + reporter := newUsageReporter(ctx, e.Identifier(), req.Model, auth) | |
| 71 | 74 | |
| 72 | 75 | mutex := state.getRequestMutex() |
| 73 | 76 | if mutex != nil { |
| @@ -81,6 +84,7 @@ func (e *GeminiWebExecutor) ExecuteStream(ctx context.Context, auth *cliproxyaut · 7 unmodified lines | ||
| 81 | 84 | } |
| 82 | 85 | return nil, geminiWebErrorFromMessage(errMsg) |
| 83 | 86 | } |
| 87 | + reporter.publish(ctx, parseGeminiUsage(gemBytes)) | |
| 84 | 88 | |
| 85 | 89 | from := opts.SourceFormat |
| 86 | 90 | to := sdktranslator.FromString("gemini-web") |
| @@ -43,6 +43,7 @@ func (e *OpenAICompatExecutor) Execute(ctx context.Context, auth *cliproxyauth.A · 42 unmodified lines | ||
| 43 | 43 | if baseURL == "" || apiKey == "" { |
| 44 | 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 | 48 | // Translate inbound request to OpenAI format |
| 48 | 49 | from := opts.SourceFormat |
| @@ -82,6 +83,7 @@ func (e *OpenAICompatExecutor) Execute(ctx context.Context, auth *cliproxyauth.A · 33 unmodified lines | ||
| 82 | 83 | return cliproxyexecutor.Response{}, err |
| 83 | 84 | } |
| 84 | 85 | appendAPIResponseChunk(ctx, e.cfg, body) |
| 86 | + reporter.publish(ctx, parseOpenAIUsage(body)) | |
| 85 | 87 | // Translate response back to source format when needed |
| 86 | 88 | var param any |
| 87 | 89 | out := sdktranslator.TranslateNonStream(ctx, to, from, req.Model, bytes.Clone(opts.OriginalRequest), translated, body, ¶m) |
| @@ -93,6 +95,7 @@ func (e *OpenAICompatExecutor) ExecuteStream(ctx context.Context, auth *cliproxy · 5 unmodified lines | ||
| 93 | 95 | if baseURL == "" || apiKey == "" { |
| 94 | 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 | 99 | from := opts.SourceFormat |
| 97 | 100 | to := sdktranslator.FromString("openai") |
| 98 | 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 | 141 | for scanner.Scan() { |
| 139 | 142 | line := scanner.Bytes() |
| 140 | 143 | appendAPIResponseChunk(ctx, e.cfg, line) |
| 144 | + if detail, ok := parseOpenAIStreamUsage(line); ok { | |
| 145 | + reporter.publish(ctx, detail) | |
| 146 | + } | |
| 141 | 147 | if len(line) == 0 { |
| 142 | 148 | continue |
| 143 | 149 | } |
| @@ -46,6 +46,7 @@ func (e *QwenExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, req · 45 unmodified lines | ||
| 46 | 46 | if baseURL == "" { |
| 47 | 47 | baseURL = "https://portal.qwen.ai/v1" |
| 48 | 48 | } |
| 49 | + reporter := newUsageReporter(ctx, e.Identifier(), req.Model, auth) | |
| 49 | 50 | |
| 50 | 51 | from := opts.SourceFormat |
| 51 | 52 | to := sdktranslator.FromString("openai") |
| @@ -79,6 +80,7 @@ func (e *QwenExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, req · 27 unmodified lines | ||
| 79 | 80 | return cliproxyexecutor.Response{}, err |
| 80 | 81 | } |
| 81 | 82 | appendAPIResponseChunk(ctx, e.cfg, data) |
| 83 | + reporter.publish(ctx, parseOpenAIUsage(data)) | |
| 82 | 84 | var param any |
| 83 | 85 | out := sdktranslator.TranslateNonStream(ctx, to, from, req.Model, bytes.Clone(opts.OriginalRequest), body, data, ¶m) |
| 84 | 86 | 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 | 94 | if baseURL == "" { |
| 93 | 95 | baseURL = "https://portal.qwen.ai/v1" |
| 94 | 96 | } |
| 97 | + reporter := newUsageReporter(ctx, e.Identifier(), req.Model, auth) | |
| 95 | 98 | |
| 96 | 99 | from := opts.SourceFormat |
| 97 | 100 | to := sdktranslator.FromString("openai") |
| @@ -103,6 +106,7 @@ func (e *QwenExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.Aut · 5 unmodified lines | ||
| 103 | 106 | if (toolsResult.IsArray() && len(toolsResult.Array()) == 0) || !toolsResult.Exists() { |
| 104 | 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 | 111 | url := strings.TrimSuffix(baseURL, "/") + "/chat/completions" |
| 108 | 112 | recordAPIRequest(ctx, e.cfg, body) |
| @@ -138,6 +142,9 @@ func (e *QwenExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.Aut · 29 unmodified lines | ||
| 138 | 142 | for scanner.Scan() { |
| 139 | 143 | line := scanner.Bytes() |
| 140 | 144 | appendAPIResponseChunk(ctx, e.cfg, line) |
| 145 | + if detail, ok := parseOpenAIStreamUsage(line); ok { | |
| 146 | + reporter.publish(ctx, detail) | |
| 147 | + } | |
| 141 | 148 | chunks := sdktranslator.TranslateStream(ctx, to, from, req.Model, bytes.Clone(opts.OriginalRequest), body, bytes.Clone(line), ¶m) |
| 142 | 149 | for i := range chunks { |
| 143 | 150 | out <- cliproxyexecutor.StreamChunk{Payload: []byte(chunks[i])} |
| @@ -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 | +} | |
| @@ -6,7 +6,6 @@ · 5 unmodified lines | ||
| 6 | 6 | package chat_completions |
| 7 | 7 | |
| 8 | 8 | import ( |
| 9 | − "bufio" | |
| 10 | 9 | "bytes" |
| 11 | 10 | "context" |
| 12 | 11 | "encoding/json" |
| @@ -278,19 +277,14 @@ func mapAnthropicStopReasonToOpenAI(anthropicReason string) string { · 265 unmodified lines | ||
| 278 | 277 | // Returns: |
| 279 | 278 | // - string: An OpenAI-compatible JSON response containing all message content and metadata |
| 280 | 279 | func ConvertClaudeResponseToOpenAINonStream(_ context.Context, _ string, originalRequestRawJSON, requestRawJSON, rawJSON []byte, _ *any) string { |
| 281 | − | |
| 282 | 280 | chunks := make([][]byte, 0) |
| 283 | 281 | |
| 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 { | |
| 290 | 284 | if !bytes.HasPrefix(line, dataTag) { |
| 291 | 285 | continue |
| 292 | 286 | } |
| 293 | − chunks = append(chunks, bytes.TrimSpace(rawJSON[5:])) | |
| 287 | + chunks = append(chunks, bytes.TrimSpace(line[5:])) | |
| 294 | 288 | } |
| 295 | 289 | |
| 296 | 290 | // Base OpenAI non-streaming response template |
| @@ -6,7 +6,6 @@ · 5 unmodified lines | ||
| 6 | 6 | package chat_completions |
| 7 | 7 | |
| 8 | 8 | import ( |
| 9 | − "bufio" | |
| 10 | 9 | "bytes" |
| 11 | 10 | "context" |
| 12 | 11 | "time" |
| @@ -166,153 +165,141 @@ func ConvertCodexResponseToOpenAI(_ context.Context, modelName string, originalR · 153 unmodified lines | ||
| 166 | 165 | // Returns: |
| 167 | 166 | // - string: An OpenAI-compatible JSON response containing all message content and metadata |
| 168 | 167 | 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 | + } | |
| 179 | 173 | |
| 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() | |
| 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. | |
| 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 | + } | |
| 195 | 184 | |
| 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 | + } | |
| 202 | 191 | |
| 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 | + } | |
| 207 | 196 | |
| 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()) | |
| 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 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 | |
| 245 | 233 | } |
| 246 | 234 | } |
| 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 | |
| 256 | 244 | } |
| 257 | 245 | } |
| 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": ""}}` | |
| 265 | 250 | |
| 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 | + } | |
| 274 | 254 | |
| 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 | |
| 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 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) | |
| 287 | 269 | } |
| 270 | + } | |
| 288 | 271 | |
| 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 | + } | |
| 293 | 277 | |
| 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") | |
| 302 | 281 | } |
| 303 | 282 | |
| 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) | |
| 310 | 288 | } |
| 289 | + template, _ = sjson.Set(template, "choices.0.message.role", "assistant") | |
| 311 | 290 | } |
| 291 | + } | |
| 312 | 292 | |
| 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 | + } | |
| 314 | 300 | } |
| 315 | − return "" | |
| 301 | + | |
| 302 | + return template | |
| 316 | 303 | } |
| 317 | 304 | |
| 318 | 305 | // buildReverseMapFromOriginalOpenAI builds a map of shortened tool name -> original tool name |
| @@ -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 | +} | |
| @@ -14,12 +14,14 @@ import ( · 13 unmodified lines | ||
| 14 | 14 | "github.com/router-for-me/CLIProxyAPI/v6/internal/config" |
| 15 | 15 | "github.com/router-for-me/CLIProxyAPI/v6/internal/registry" |
| 16 | 16 | "github.com/router-for-me/CLIProxyAPI/v6/internal/runtime/executor" |
| 17 | + _ "github.com/router-for-me/CLIProxyAPI/v6/internal/usage" | |
| 17 | 18 | "github.com/router-for-me/CLIProxyAPI/v6/internal/util" |
| 18 | 19 | "github.com/router-for-me/CLIProxyAPI/v6/internal/watcher" |
| 19 | 20 | sdkaccess "github.com/router-for-me/CLIProxyAPI/v6/sdk/access" |
| 20 | 21 | _ "github.com/router-for-me/CLIProxyAPI/v6/sdk/access/providers/configapikey" |
| 21 | 22 | sdkAuth "github.com/router-for-me/CLIProxyAPI/v6/sdk/auth" |
| 22 | 23 | coreauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth" |
| 24 | + "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/usage" | |
| 23 | 25 | log "github.com/sirupsen/logrus" |
| 24 | 26 | ) |
| 25 | 27 | |
| @@ -51,6 +53,11 @@ type Service struct { · 25 unmodified lines | ||
| 51 | 53 | shutdownOnce sync.Once |
| 52 | 54 | } |
| 53 | 55 | |
| 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 | + | |
| 54 | 61 | func newDefaultAuthManager() *sdkAuth.Manager { |
| 55 | 62 | return sdkAuth.NewManager( |
| 56 | 63 | sdkAuth.NewFileTokenStore(), |
| @@ -217,6 +224,8 @@ func (s *Service) Run(ctx context.Context) error { · 160 unmodified lines | ||
| 217 | 224 | ctx = context.Background() |
| 218 | 225 | } |
| 219 | 226 | |
| 227 | + usage.StartDefault(ctx) | |
| 228 | + | |
| 220 | 229 | shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 30*time.Second) |
| 221 | 230 | defer shutdownCancel() |
| 222 | 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 | 403 | return shutdownErr |
| 393 | 404 | } |
| @@ -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() } | |