- 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 | 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.SourceFormat | 47 | 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{}, err | 96 | 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 any | 109 | var param any |
| 98 | out := sdktranslator.TranslateNonStream(ctx, to, from, req.Model, bytes.Clone(opts.OriginalRequest), body, data, ¶m) | 110 | out := sdktranslator.TranslateNonStream(ctx, to, from, req.Model, bytes.Clone(opts.OriginalRequest), body, data, ¶m) |
| 99 | return cliproxyexecutor.Response{Payload: []byte(out)}, nil | 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 | 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.SourceFormat | 123 | 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), ¶m) | 165 | chunks := sdktranslator.TranslateStream(ctx, to, from, req.Model, bytes.Clone(opts.OriginalRequest), body, bytes.Clone(line), ¶m) |
| 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])} |
| @@ -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 | ||
| 28 | var 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. |
| 29 | type CodexExecutor struct { | 32 | type 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.SourceFormat | 52 | 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{}, err | 109 | return cliproxyexecutor.Response{}, err |
| 104 | } | 110 | } |
| 105 | appendAPIResponseChunk(ctx, e.cfg, data) | 111 | appendAPIResponseChunk(ctx, e.cfg, data) |
| 106 | var param any | 112 | |
| 107 | out := sdktranslator.TranslateNonStream(ctx, to, from, req.Model, bytes.Clone(opts.OriginalRequest), body, data, ¶m) | 113 | lines := bytes.Split(data, []byte("\n")) |
| 108 | return cliproxyexecutor.Response{Payload: []byte(out)}, nil | 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 | func (e *CodexExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (<-chan cliproxyexecutor.StreamChunk, error) { | 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 | 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.SourceFormat | 145 | 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), ¶m) | 219 | chunks := sdktranslator.TranslateStream(ctx, to, from, req.Model, bytes.Clone(opts.OriginalRequest), body, bytes.Clone(line), ¶m) |
| 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])} |
| @@ -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{}, err | 55 | return cliproxyexecutor.Response{}, err |
| 56 | } | 56 | } |
| 57 | reporter := newUsageReporter(ctx, e.Identifier(), req.Model, auth) | ||
| 57 | 58 | ||
| 58 | from := opts.SourceFormat | 59 | 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 any | 122 | var param any |
| 121 | out := sdktranslator.TranslateNonStream(respCtx, to, from, attemptModel, bytes.Clone(opts.OriginalRequest), payload, data, ¶m) | 123 | out := sdktranslator.TranslateNonStream(respCtx, to, from, attemptModel, bytes.Clone(opts.OriginalRequest), payload, data, ¶m) |
| 122 | return cliproxyexecutor.Response{Payload: []byte(out)}, nil | 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 | if err != nil { | 141 | if err != nil { |
| 140 | return nil, err | 142 | return nil, err |
| 141 | } | 143 | } |
| 144 | reporter := newUsageReporter(ctx, e.Identifier(), req.Model, auth) | ||
| 142 | 145 | ||
| 143 | from := opts.SourceFormat | 146 | 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), ¶m) | 225 | segments := sdktranslator.TranslateStream(respCtx, to, from, attempt, bytes.Clone(opts.OriginalRequest), reqBody, bytes.Clone(line), ¶m) |
| 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 | return | 245 | return |
| 240 | } | 246 | } |
| 241 | appendAPIResponseChunk(ctx, e.cfg, data) | 247 | appendAPIResponseChunk(ctx, e.cfg, data) |
| 248 | reporter.publish(ctx, parseGeminiCLIUsage(data)) | ||
| 242 | var param any | 249 | var param any |
| 243 | segments := sdktranslator.TranslateStream(respCtx, to, from, attempt, bytes.Clone(opts.OriginalRequest), reqBody, data, ¶m) | 250 | segments := sdktranslator.TranslateStream(respCtx, to, from, attempt, bytes.Clone(opts.OriginalRequest), reqBody, data, ¶m) |
| 244 | for i := range segments { | 251 | for i := range segments { |
| @@ -43,6 +43,7 @@ func (e *GeminiExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, r · 42 unmodified lines | |||
| 43 | // Fallback to legacy client | 43 | // 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 bearer | 48 | // Official Gemini API via API key or OAuth bearer |
| 48 | from := opts.SourceFormat | 49 | 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{}, err | 93 | 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 any | 97 | var param any |
| 96 | out := sdktranslator.TranslateNonStream(ctx, to, from, req.Model, bytes.Clone(opts.OriginalRequest), body, data, ¶m) | 98 | out := sdktranslator.TranslateNonStream(ctx, to, from, req.Model, bytes.Clone(opts.OriginalRequest), body, data, ¶m) |
| 97 | return cliproxyexecutor.Response{Payload: []byte(out)}, nil | 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 | // Fallback to legacy streaming | 105 | // 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.SourceFormat | 110 | 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), ¶m) | 161 | lines := sdktranslator.TranslateStream(ctx, to, from, req.Model, bytes.Clone(opts.OriginalRequest), body, bytes.Clone(line), ¶m) |
| 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])} |
| @@ -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{}, err | 39 | 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.SourceFormat | 57 | 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, err | 71 | 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.SourceFormat | 89 | from := opts.SourceFormat |
| 86 | to := sdktranslator.FromString("gemini-web") | 90 | to := sdktranslator.FromString("gemini-web") |
| @@ -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 format | 48 | // Translate inbound request to OpenAI format |
| 48 | from := opts.SourceFormat | 49 | from := opts.SourceFormat |
| @@ -82,6 +83,7 @@ func (e *OpenAICompatExecutor) Execute(ctx context.Context, auth *cliproxyauth.A · 33 unmodified lines | |||
| 82 | return cliproxyexecutor.Response{}, err | 83 | 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 needed | 87 | // Translate response back to source format when needed |
| 86 | var param any | 88 | var param any |
| 87 | out := sdktranslator.TranslateNonStream(ctx, to, from, req.Model, bytes.Clone(opts.OriginalRequest), translated, body, ¶m) | 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 | 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.SourceFormat | 99 | 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 | continue | 148 | continue |
| 143 | } | 149 | } |
| @@ -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.SourceFormat | 51 | 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{}, err | 80 | 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 any | 84 | var param any |
| 83 | out := sdktranslator.TranslateNonStream(ctx, to, from, req.Model, bytes.Clone(opts.OriginalRequest), body, data, ¶m) | 85 | out := sdktranslator.TranslateNonStream(ctx, to, from, req.Model, bytes.Clone(opts.OriginalRequest), body, data, ¶m) |
| 84 | return cliproxyexecutor.Response{Payload: []byte(out)}, nil | 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 | 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.SourceFormat | 99 | 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), ¶m) | 148 | chunks := sdktranslator.TranslateStream(ctx, to, from, req.Model, bytes.Clone(opts.OriginalRequest), body, bytes.Clone(line), ¶m) |
| 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])} |
| @@ -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 | package chat_completions | 6 | package chat_completions |
| 7 | 7 | ||
| 8 | import ( | 8 | import ( |
| 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 metadata | 278 | // - string: An OpenAI-compatible JSON response containing all message content and metadata |
| 280 | func ConvertClaudeResponseToOpenAINonStream(_ context.Context, _ string, originalRequestRawJSON, requestRawJSON, rawJSON []byte, _ *any) string { | 279 | func 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 | continue | 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 | // Base OpenAI non-streaming response template | 290 | // Base OpenAI non-streaming response template |
| @@ -6,7 +6,6 @@ · 5 unmodified lines | |||
| 6 | package chat_completions | 6 | package chat_completions |
| 7 | 7 | ||
| 8 | import ( | 8 | import ( |
| 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 metadata | 166 | // - string: An OpenAI-compatible JSON response containing all message content and metadata |
| 168 | func ConvertCodexResponseToOpenAINonStream(_ context.Context, _ string, originalRequestRawJSON, requestRawJSON, rawJSON []byte, _ *any) string { | 167 | func 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 calls | 213 | // 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 string | 217 | var contentText string |
| 229 | var reasoningText string | 218 | var reasoningText string |
| 230 | var toolCalls []string | 219 | 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 summary | 226 | // 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 | break | 232 | break |
| 244 | } | ||
| 245 | } | 233 | } |
| 246 | } | 234 | } |
| 247 | case "message": | 235 | } |
| 248 | // Extract message content | 236 | 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 | break | 242 | contentText = contentItem.Get("text").String() |
| 255 | } | 243 | break |
| 256 | } | 244 | } |
| 257 | } | 245 | } |
| 258 | case "function_call": | 246 | } |
| 259 | // Handle function call content | 247 | 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 found | 268 | 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 any | 278 | 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 status | 283 | // 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 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 | // buildReverseMapFromOriginalOpenAI builds a map of shortened tool name -> original tool name | 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 | "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.Once | 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 | func newDefaultAuthManager() *sdkAuth.Manager { | 61 | func 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 shutdownErr | 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() } | ||