diff --git a/internal/api/handlers/management/auth_files.go b/internal/api/handlers/management/auth_files.go index 5d4026e8..68f71515 100644 --- a/internal/api/handlers/management/auth_files.go +++ b/internal/api/handlers/management/auth_files.go @@ -292,6 +292,7 @@ func (h *Handler) buildAuthFileEntry(auth *coreauth.Auth) gin.H { if auth == nil { return nil } + auth.EnsureIndex() runtimeOnly := isRuntimeOnlyAuth(auth) if runtimeOnly && (auth.Disabled || auth.Status == coreauth.StatusDisabled) { return nil @@ -306,6 +307,7 @@ func (h *Handler) buildAuthFileEntry(auth *coreauth.Auth) gin.H { } entry := gin.H{ "id": auth.ID, + "auth_index": auth.Index, "name": name, "type": strings.TrimSpace(auth.Provider), "provider": strings.TrimSpace(auth.Provider), diff --git a/internal/runtime/executor/iflow_executor.go b/internal/runtime/executor/iflow_executor.go index 0ed6c0db..3589e922 100644 --- a/internal/runtime/executor/iflow_executor.go +++ b/internal/runtime/executor/iflow_executor.go @@ -112,6 +112,8 @@ func (e *IFlowExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, re } appendAPIResponseChunk(ctx, e.cfg, data) reporter.publish(ctx, parseOpenAIUsage(data)) + // Ensure usage is recorded even if upstream omits usage metadata. + reporter.ensurePublished(ctx) var param any out := sdktranslator.TranslateNonStream(ctx, to, from, req.Model, bytes.Clone(opts.OriginalRequest), body, data, ¶m) @@ -217,6 +219,8 @@ func (e *IFlowExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.Au reporter.publishFailure(ctx) out <- cliproxyexecutor.StreamChunk{Err: errScan} } + // Guarantee a usage record exists even if the stream never emitted usage data. + reporter.ensurePublished(ctx) }() return stream, nil diff --git a/internal/runtime/executor/usage_helpers.go b/internal/runtime/executor/usage_helpers.go index a3cd0922..be38355d 100644 --- a/internal/runtime/executor/usage_helpers.go +++ b/internal/runtime/executor/usage_helpers.go @@ -18,6 +18,7 @@ type usageReporter struct { provider string model string authID string + authIndex uint64 apiKey string source string requestedAt time.Time @@ -35,6 +36,7 @@ func newUsageReporter(ctx context.Context, provider, model string, auth *cliprox } if auth != nil { reporter.authID = auth.ID + reporter.authIndex = auth.Index } return reporter } @@ -76,6 +78,7 @@ func (r *usageReporter) publishWithOutcome(ctx context.Context, detail usage.Det Source: r.source, APIKey: r.apiKey, AuthID: r.authID, + AuthIndex: r.authIndex, RequestedAt: r.requestedAt, Failed: failed, Detail: detail, @@ -98,6 +101,7 @@ func (r *usageReporter) ensurePublished(ctx context.Context) { Source: r.source, APIKey: r.apiKey, AuthID: r.authID, + AuthIndex: r.authIndex, RequestedAt: r.requestedAt, Failed: false, Detail: usage.Detail{}, diff --git a/internal/usage/logger_plugin.go b/internal/usage/logger_plugin.go index 64c61d87..141ad997 100644 --- a/internal/usage/logger_plugin.go +++ b/internal/usage/logger_plugin.go @@ -90,6 +90,7 @@ type modelStats struct { type RequestDetail struct { Timestamp time.Time `json:"timestamp"` Source string `json:"source"` + AuthIndex uint64 `json:"auth_index"` Tokens TokenStats `json:"tokens"` Failed bool `json:"failed"` } @@ -197,6 +198,7 @@ func (s *RequestStatistics) Record(ctx context.Context, record coreusage.Record) s.updateAPIStats(stats, modelName, RequestDetail{ Timestamp: timestamp, Source: record.Source, + AuthIndex: record.AuthIndex, Tokens: detail, Failed: failed, }) diff --git a/sdk/cliproxy/auth/manager.go b/sdk/cliproxy/auth/manager.go index fe41cfe3..f195c0e9 100644 --- a/sdk/cliproxy/auth/manager.go +++ b/sdk/cliproxy/auth/manager.go @@ -169,6 +169,7 @@ func (m *Manager) Register(ctx context.Context, auth *Auth) (*Auth, error) { if auth == nil { return nil, nil } + auth.EnsureIndex() if auth.ID == "" { auth.ID = uuid.NewString() } @@ -185,6 +186,7 @@ func (m *Manager) Update(ctx context.Context, auth *Auth) (*Auth, error) { if auth == nil || auth.ID == "" { return nil, nil } + auth.EnsureIndex() m.mu.Lock() m.auths[auth.ID] = auth.Clone() m.mu.Unlock() @@ -209,6 +211,7 @@ func (m *Manager) Load(ctx context.Context) error { if auth == nil || auth.ID == "" { continue } + auth.EnsureIndex() m.auths[auth.ID] = auth.Clone() } return nil diff --git a/sdk/cliproxy/auth/types.go b/sdk/cliproxy/auth/types.go index 8984554d..25e88b96 100644 --- a/sdk/cliproxy/auth/types.go +++ b/sdk/cliproxy/auth/types.go @@ -5,6 +5,7 @@ import ( "strconv" "strings" "sync" + "sync/atomic" "time" baseauth "github.com/router-for-me/CLIProxyAPI/v6/internal/auth" @@ -14,6 +15,8 @@ import ( type Auth struct { // ID uniquely identifies the auth record across restarts. ID string `json:"id"` + // Index is a monotonically increasing runtime identifier used for diagnostics. + Index uint64 `json:"-"` // Provider is the upstream provider key (e.g. "gemini", "claude"). Provider string `json:"provider"` // FileName stores the relative or absolute path of the backing auth file. @@ -55,6 +58,8 @@ type Auth struct { // Runtime carries non-serialisable data used during execution (in-memory only). Runtime any `json:"-"` + + indexAssigned bool `json:"-"` } // QuotaState contains limiter tracking data for a credential. @@ -87,6 +92,12 @@ type ModelState struct { UpdatedAt time.Time `json:"updated_at"` } +var authIndexCounter atomic.Uint64 + +func nextAuthIndex() uint64 { + return authIndexCounter.Add(1) - 1 +} + // Clone shallow copies the Auth structure, duplicating maps to avoid accidental mutation. func (a *Auth) Clone() *Auth { if a == nil { @@ -115,6 +126,20 @@ func (a *Auth) Clone() *Auth { return ©Auth } +// EnsureIndex returns the global index, assigning one if it was not set yet. +func (a *Auth) EnsureIndex() uint64 { + if a == nil { + return 0 + } + if a.indexAssigned { + return a.Index + } + idx := nextAuthIndex() + a.Index = idx + a.indexAssigned = true + return idx +} + // Clone duplicates a model state including nested error details. func (m *ModelState) Clone() *ModelState { if m == nil { diff --git a/sdk/cliproxy/usage/manager.go b/sdk/cliproxy/usage/manager.go index 9ef73356..9e81cae5 100644 --- a/sdk/cliproxy/usage/manager.go +++ b/sdk/cliproxy/usage/manager.go @@ -14,6 +14,7 @@ type Record struct { Model string APIKey string AuthID string + AuthIndex uint64 Source string RequestedAt time.Time Failed bool