Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 17 additions & 5 deletions internal/providers/opencode/provider.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,15 +7,13 @@ import (
"net/http"
"strings"

"github.com/janekbaraniewski/openusage/internal/config"
"github.com/janekbaraniewski/openusage/internal/core"
"github.com/janekbaraniewski/openusage/internal/providers/providerbase"
"github.com/janekbaraniewski/openusage/internal/providers/shared"
)

var (
loadBrowserSession = shared.LoadOrRefreshBrowserSession
newConsoleClient = NewConsoleClient
)
var newConsoleClient = NewConsoleClient

// OpenCode Zen exposes only OpenAI-compatible chat/messages/models endpoints
// behind its API-key auth (verified via reverse-engineering against the
Expand Down Expand Up @@ -145,12 +143,26 @@ func (p *Provider) Fetch(ctx context.Context, acct core.AccountConfig) (core.Usa

var errNoCookieConfigured = errors.New("opencode: no browser session configured")

// loadStoredSession reads a browser session directly from the credentials file
// without refreshing from the browser. This avoids the destructive refresh in
// LoadOrRefreshBrowserSession that overwrites stored sessions when multiple
// accounts use different browsers for the same domain.
//
// A package-level var (not a plain func) so tests can stub it — mirrors the
// newConsoleClient seam above.
var loadStoredSession = func(accountID string) (config.BrowserSession, bool, error) {
return config.LoadSession(accountID)
}

// enrichFromConsole loads the stored browser session for the account, calls
// the OpenCode console RPCs, and merges the results into the snapshot's
// metrics + attributes. Returns errNoCookieConfigured when the user hasn't
// opted in to browser-session auth.
func (p *Provider) enrichFromConsole(ctx context.Context, acct core.AccountConfig, snap *core.UsageSnapshot) error {
session, ok, err := loadBrowserSession(ctx, acct, nil)
// Load directly from stored credentials to avoid the browser refresh
// in LoadOrRefreshBrowserSession, which can overwrite stored sessions
// when multiple accounts use different browsers for the same domain.
session, ok, err := loadStoredSession(acct.ID)
if err != nil || !ok || session.Value == "" {
return errNoCookieConfigured
}
Expand Down
22 changes: 11 additions & 11 deletions internal/providers/opencode/provider_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,19 +8,17 @@ import (
"strings"
"testing"

"github.com/janekbaraniewski/openusage/internal/browsercookies"
"github.com/janekbaraniewski/openusage/internal/config"
"github.com/janekbaraniewski/openusage/internal/core"
)

// TestMain neutralizes the browser-session lookup for the whole package. Left at
// its default, Fetch reads the developer's real browser cookie store, and on
// macOS that blocks in a Keychain prompt no test binary can answer — the test
// hangs until the 10m timeout. It only passes in CI because there is no browser
// profile there. Tests that exercise console enrichment override this seam with
// a session of their own.
// TestMain neutralizes the stored-session lookup for the whole package. Left at
// its default it reads the developer's real credentials file, so a machine with
// a connected OpenCode account would drive console enrichment against the live
// service instead of the test server. Tests that exercise console enrichment
// override this seam with a session of their own.
func TestMain(m *testing.M) {
loadBrowserSession = func(context.Context, core.AccountConfig, browsercookies.Reader) (config.BrowserSession, bool, error) {
loadStoredSession = func(string) (config.BrowserSession, bool, error) {
return config.BrowserSession{}, false, nil
}
os.Exit(m.Run())
Expand Down Expand Up @@ -140,14 +138,16 @@ func TestFetch_RateLimited_429(t *testing.T) {
}

func TestFetch_ConsoleEnrichmentAutoDiscoversWorkspaceID(t *testing.T) {
origLoadBrowserSession := loadBrowserSession
origLoadStoredSession := loadStoredSession
origNewConsoleClient := newConsoleClient
t.Cleanup(func() {
loadBrowserSession = origLoadBrowserSession
loadStoredSession = origLoadStoredSession
newConsoleClient = origNewConsoleClient
})

loadBrowserSession = func(context.Context, core.AccountConfig, browsercookies.Reader) (config.BrowserSession, bool, error) {
// enrichFromConsole calls loadStoredSession, a pure credentials-file
// read with no browser refresh.
loadStoredSession = func(accountID string) (config.BrowserSession, bool, error) {
return config.BrowserSession{
Value: "test-cookie-value",
CookieName: "auth",
Expand Down
20 changes: 15 additions & 5 deletions internal/providers/perplexity/perplexity.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,15 +22,18 @@ import (
"strings"
"time"

"github.com/janekbaraniewski/openusage/internal/config"
"github.com/janekbaraniewski/openusage/internal/core"
"github.com/janekbaraniewski/openusage/internal/providers/providerbase"
"github.com/janekbaraniewski/openusage/internal/providers/shared"
)

// loadBrowserSession is a seam so tests can supply a session instead of reading
// the developer's real browser cookie store, which on macOS blocks in a Keychain
// prompt no test binary can answer.
var loadBrowserSession = shared.LoadOrRefreshBrowserSession
// loadStoredSession is a seam so tests can supply a session instead of reading
// the developer's real credentials file. It deliberately does not refresh from
// the browser cookie store: doing so on every poll overwrites the stored
// session and clobbers a sibling account that shares this provider's cookie
// domain but was connected from a different browser.
var loadStoredSession = config.LoadSession

const (
consoleBaseURL = "https://console.perplexity.ai"
Expand Down Expand Up @@ -82,7 +85,14 @@ func New() *Provider {
func (p *Provider) Fetch(ctx context.Context, acct core.AccountConfig) (core.UsageSnapshot, error) {
snap := core.NewUsageSnapshot(p.ID(), acct.ID)

session, ok, err := loadBrowserSession(ctx, acct, nil)
// Load directly from stored credentials rather than
// shared.LoadOrRefreshBrowserSession, which re-reads the live browser
// cookie and overwrites the stored session on every poll — clobbering a
// sibling account's session when two accounts share this provider's
// fixed cookie domain but use different source browsers. See the
// equivalent opencode fix (loadStoredSession in
// internal/providers/opencode/provider.go) for the bug this avoids.
session, ok, err := loadStoredSession(acct.ID)
if err != nil || !ok || session.Value == "" {
snap.Status = core.StatusAuth
snap.Message = "browser session not configured — Settings → 5 KEYS → perplexity → Enter"
Expand Down
11 changes: 5 additions & 6 deletions internal/providers/perplexity/perplexity_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@ import (
"strings"
"testing"

"github.com/janekbaraniewski/openusage/internal/browsercookies"
"github.com/janekbaraniewski/openusage/internal/config"
"github.com/janekbaraniewski/openusage/internal/core"
)
Expand All @@ -33,14 +32,14 @@ func isolateConfigDir(t *testing.T) {
}
}

// stubNoBrowserSession makes the browser-session lookup report "none configured"
// without touching the developer's real browsers or Keychain.
// stubNoBrowserSession makes the stored-session lookup report "none configured"
// without touching the developer's real credentials file.
func stubNoBrowserSession(t *testing.T) {
t.Helper()

orig := loadBrowserSession
t.Cleanup(func() { loadBrowserSession = orig })
loadBrowserSession = func(context.Context, core.AccountConfig, browsercookies.Reader) (config.BrowserSession, bool, error) {
orig := loadStoredSession
t.Cleanup(func() { loadStoredSession = orig })
loadStoredSession = func(string) (config.BrowserSession, bool, error) {
return config.BrowserSession{}, false, nil
}
}
Expand Down
45 changes: 41 additions & 4 deletions internal/telemetry/usage_view.go
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,32 @@ func applyCanonicalUsageViewWithDB(
}
core.Tracef("[usage_view_perf] queryTelemetryActiveProviders: %dms", time.Since(activeStart).Milliseconds())

// Accounts sharing the same source-provider set are ambiguous for the
// provider-scope fallback below: if account-scoped telemetry is empty
// for one of several sibling accounts, falling back to a provider-wide
// query would leak a sibling account's usage into this one. Only allow
// the fallback when a source-provider set maps to exactly one account.
siblingAccountsByProviderKey := make(map[string]map[string]bool, len(snaps))
for accountID, snap := range snaps {
providerID := strings.TrimSpace(snap.ProviderID)
if providerID == "" {
continue
}
accountScope := strings.TrimSpace(snap.AccountID)
if accountScope == "" {
accountScope = strings.TrimSpace(accountID)
}
sourceProviders := telemetrySourceProvidersForTarget(providerID, providerLinks)
if len(sourceProviders) == 0 {
continue
}
providerKey := strings.Join(sourceProviders, ",")
if siblingAccountsByProviderKey[providerKey] == nil {
siblingAccountsByProviderKey[providerKey] = make(map[string]bool, 1)
}
siblingAccountsByProviderKey[providerKey][accountScope] = true
}

for accountID, snap := range snaps {
s := snap
providerID := strings.TrimSpace(s.ProviderID)
Expand All @@ -188,10 +214,13 @@ func applyCanonicalUsageViewWithDB(
continue
}

cacheKey := strings.Join(sourceProviders, ",") + "|" + accountScope
providerKey := strings.Join(sourceProviders, ",")
allowProviderFallback := len(siblingAccountsByProviderKey[providerKey]) <= 1

cacheKey := providerKey + "|" + accountScope
agg, ok := cache[cacheKey]
if !ok {
loaded, loadErr := loadUsageViewForProviderWithSources(ctx, db, cacheNamespace, sourceProviders, accountScope, since, todaySince)
loaded, loadErr := loadUsageViewForProviderWithSources(ctx, db, cacheNamespace, sourceProviders, accountScope, allowProviderFallback, since, todaySince)
if loadErr != nil {
return snaps, loadErr
}
Expand Down Expand Up @@ -268,7 +297,7 @@ func queryTelemetryActiveProviders(ctx context.Context, db *sql.DB) (map[string]
return out, nil
}

func loadUsageViewForProviderWithSources(ctx context.Context, db *sql.DB, cacheNamespace usageViewCacheNamespace, providerIDs []string, accountID string, since time.Time, todaySince time.Time) (*telemetryUsageAgg, error) {
func loadUsageViewForProviderWithSources(ctx context.Context, db *sql.DB, cacheNamespace usageViewCacheNamespace, providerIDs []string, accountID string, allowProviderFallback bool, since time.Time, todaySince time.Time) (*telemetryUsageAgg, error) {
providerIDs = normalizeProviderIDs(providerIDs)
if len(providerIDs) == 0 {
return &telemetryUsageAgg{}, nil
Expand All @@ -294,7 +323,15 @@ func loadUsageViewForProviderWithSources(ctx context.Context, db *sql.DB, cacheN
scoped.AccountID = accountID
return scoped, nil
}
// Fall through to provider-scoped query if no account-scoped events found.
// No account-scoped events. Only fall through to the provider-scoped
// query when this account is the sole account for its provider —
// otherwise the provider-wide aggregate would include a sibling
// account's usage and misattribute it to this one.
if !allowProviderFallback {
scoped.Scope = "account"
scoped.AccountID = accountID
return scoped, nil
}
}

fallback, err := loadUsageViewForFilter(ctx, db, cacheNamespace, usageFilter{
Expand Down
53 changes: 53 additions & 0 deletions internal/telemetry/usage_view_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -451,6 +451,59 @@ func TestApplyCanonicalUsageView_FallsBackToProviderScopeForAccountView(t *testi
}
}

func TestApplyCanonicalUsageView_DoesNotLeakProviderScopeAcrossSiblingAccounts(t *testing.T) {
dbPath, store := openUsageViewTestStore(t)

occurredAt := time.Date(2026, 2, 23, 7, 30, 0, 0, time.UTC)
input := int64(77)
total := int64(77)
if _, err := store.Ingest(context.Background(), IngestRequest{
SourceSystem: SourceSystem("opencode"),
SourceChannel: SourceChannelHook,
OccurredAt: occurredAt,
ProviderID: "opencode",
AccountID: "opencode",
AgentName: "opencode",
EventType: EventTypeMessageUsage,
SessionID: "sess-a",
MessageID: "msg-a",
ModelRaw: "claude-4.6-opus-high-thinking",
TokenUsage: core.TokenUsage{
InputTokens: &input,
TotalTokens: &total,
Requests: int64Ptr(1),
},
}); err != nil {
t.Fatalf("ingest usage event: %v", err)
}

// Two accounts share the "opencode" provider (e.g. two browser-session
// accounts). Only "opencode" has locally-tagged usage telemetry; the
// sibling "opencode-personal" account has none. The provider-scope
// fallback must not leak the "opencode" account's usage into
// "opencode-personal" just because they share a provider.
snaps := map[string]core.UsageSnapshot{
"opencode": {
ProviderID: "opencode",
AccountID: "opencode",
},
"opencode-personal": {
ProviderID: "opencode",
AccountID: "opencode-personal",
},
}

merged, err := applyCanonicalUsageViewForTest(context.Background(), dbPath, snaps)
if err != nil {
t.Fatalf("apply canonical usage view: %v", err)
}

personal := merged["opencode-personal"]
if _, ok := personal.Metrics["client_opencode_requests"]; ok {
t.Fatalf("opencode-personal picked up sibling account's usage metrics via provider-scope fallback: %+v", personal.Metrics)
}
}

func TestApplyCanonicalUsageView_ClearsStalePrefixedAttributeAndDiagnosticKeys(t *testing.T) {
dbPath, store := openUsageViewTestStore(t)

Expand Down
Loading