From 270d500e0454cf03a93b040c6dd65787282cc144 Mon Sep 17 00:00:00 2001 From: hiyukoim Date: Fri, 24 Jul 2026 12:29:45 +0200 Subject: [PATCH 1/4] feat(opencode): add OpenCode Go provider on upstream/main Rebuild PR work cleanly from upstream (keeps kimi/moonshot/deepseek). Hardened scrape client, tracker wiring, typed errors, dashboard/UI, limit-field contract test, and empty-state/history frontend fixes. Co-authored-by: Cursor --- docs/OPENCODE_SETUP.md | 176 ++++++ internal/agent/opencode_agent.go | 133 +++++ internal/agent/opencode_agent_test.go | 185 +++++++ internal/api/opencode.go | 34 ++ internal/api/opencode_client.go | 367 +++++++++++++ internal/api/opencode_client_test.go | 238 ++++++++ internal/config/config.go | 18 + internal/store/opencode_store.go | 523 ++++++++++++++++++ internal/store/opencode_store_test.go | 116 ++++ internal/store/store.go | 37 ++ internal/tracker/opencode_tracker.go | 272 ++++++++++ internal/tracker/opencode_tracker_test.go | 172 ++++++ internal/web/handlers.go | 35 ++ internal/web/opencode_handlers.go | 628 ++++++++++++++++++++++ internal/web/opencode_handlers_test.go | 209 +++++++ internal/web/static/app.js | 200 ++++++- internal/web/static/icons/opencode.svg | 4 + internal/web/static/menubar.html | 7 + internal/web/templates/dashboard.html | 21 +- main.go | 29 +- 20 files changed, 3388 insertions(+), 16 deletions(-) create mode 100644 docs/OPENCODE_SETUP.md create mode 100644 internal/agent/opencode_agent.go create mode 100644 internal/agent/opencode_agent_test.go create mode 100644 internal/api/opencode.go create mode 100644 internal/api/opencode_client.go create mode 100644 internal/api/opencode_client_test.go create mode 100644 internal/store/opencode_store.go create mode 100644 internal/store/opencode_store_test.go create mode 100644 internal/tracker/opencode_tracker.go create mode 100644 internal/tracker/opencode_tracker_test.go create mode 100644 internal/web/opencode_handlers.go create mode 100644 internal/web/opencode_handlers_test.go create mode 100644 internal/web/static/icons/opencode.svg diff --git a/docs/OPENCODE_SETUP.md b/docs/OPENCODE_SETUP.md new file mode 100644 index 00000000..e6eb7754 --- /dev/null +++ b/docs/OPENCODE_SETUP.md @@ -0,0 +1,176 @@ +# OpenCode Go Setup Guide + +Track OpenCode Go subscription quotas in onWatch. + +OpenCode Go does not expose a public quota API. onWatch scrapes the authenticated OpenCode Go dashboard the same way other tooling does: with your workspace ID and browser `auth` cookie. + +--- + +## Prerequisites + +- An active [OpenCode Go](https://opencode.ai) subscription +- Access to the OpenCode Go dashboard in a browser +- onWatch installed ([Quick Start](../README.md#quick-start)) + +--- + +## How It Works + +onWatch polls: + +```text +https://opencode.ai/workspace/{workspaceId}/go +``` + +using your session cookie, then extracts utilization and reset countdowns for: + +- **5-Hour** (rolling / session window) +- **Weekly** +- **Monthly** (when present on the dashboard) + +Parsing tries SolidJS SSR hydration data first, then falls back to the newer `data-slot="usage-item"` HTML layout. Snapshots are stored locally in SQLite like every other provider. + +This is separate from `OPENCODE_ENABLED`, which only feeds ChatGPT credentials from OpenCode into the **Codex** provider. + +--- + +## 1. Find Your Workspace ID + +1. Open https://opencode.ai and sign in +2. Open your OpenCode Go usage page. The URL looks like: + +```text +https://opencode.ai/workspace/wrk_xxxxxxxx/go +``` + +3. Copy the `wrk_...` segment. That is your `OPENCODE_GO_WORKSPACE_ID`. + +--- + +## 2. Copy the Auth Cookie + +1. While signed in on opencode.ai, open your browser Developer Tools +2. Go to **Application** / **Storage** → **Cookies** → `https://opencode.ai` +3. Find the `auth` cookie +4. Copy its **value** (not the `auth=` name prefix) + +Treat this cookie like a password. Logging out of OpenCode, rotating sessions, or clearing cookies will invalidate it. + +--- + +## 3. Configure onWatch + +Add both values to `~/.onwatch/.env` (or your project `.env`): + +```bash +OPENCODE_GO_WORKSPACE_ID=wrk_xxxxxxxx +OPENCODE_GO_AUTH_COOKIE=your_auth_cookie_value +``` + +Both are required. If either is missing, the OpenCode Go provider stays disabled. + +You can also set them in the dashboard: + +1. Open **Settings → Providers → OpenCode Go** +2. Paste **Workspace ID** and **Auth Cookie** +3. Save + +Dashboard values override `.env` for the running process. A daemon restart may still be needed depending on how the agent was started. + +--- + +## 4. Reload / Restart + +Reload providers from Settings if available, or restart onWatch: + +```bash +onwatch stop +onwatch +``` + +Or verify in the foreground: + +```bash +onwatch --debug +``` + +You should see the OpenCode agent start when both credentials are present. + +--- + +## 5. Verify + +- Open http://localhost:9211 +- Switch to the **OpenCode** tab +- Confirm 5-Hour / Weekly cards populate (Monthly appears when OpenCode returns it) +- Charts, cycle overview, and insights begin filling after a few polls + +--- + +## Dashboard + +The OpenCode Go tab shows: + +- Quota cards with utilization, remaining countdown, and status +- Historical chart across tracked windows +- Billing-cycle / usage-sample tables +- Burn-rate insights for the active windows + +--- + +## Security Notes + +- Never commit `.env` or paste the cookie into issue reports / logs +- onWatch redacts `auth_cookie` from `/api/settings` responses +- Scraped HTML is not written to logs +- All processing stays local on your machine + +--- + +## Limitations & Notes + +- This integration depends on undocumented dashboard HTML. OpenCode UI changes can break parsing until onWatch is updated. +- Auth failures and parse failures are surfaced as errors. onWatch does **not** invent fake currency quotas when scraping fails. +- Cookie lifetime is controlled by OpenCode. Expect to refresh the cookie after logout or session rotation. +- Workspace ID is required; onWatch does not auto-discover workspaces. + +--- + +## Troubleshooting + +### No OpenCode tab + +- Confirm both `OPENCODE_GO_WORKSPACE_ID` and `OPENCODE_GO_AUTH_COOKIE` are set +- Restart onWatch and check `--debug` logs for missing-config messages +- In Settings → Providers, confirm OpenCode Go shows as configured / polling + +### Unauthorized / forbidden / empty data + +1. Re-copy a fresh `auth` cookie while signed in +2. Confirm the workspace ID matches the `/go` URL +3. Restart onWatch +4. Open the Go dashboard in your browser and verify the page still loads + +### Parse failed / response format changed + +OpenCode likely changed the dashboard markup. File an issue with: + +- Approximate time of failure +- Whether the browser dashboard still shows 5h / weekly / monthly +- **Do not** attach cookies or full HTML dumps with session data + +### Docker / headless + +Pass both env vars into the container. There is no local cookie auto-detection path for OpenCode Go. + +```bash +OPENCODE_GO_WORKSPACE_ID=wrk_xxxxxxxx +OPENCODE_GO_AUTH_COOKIE=your_auth_cookie_value +``` + +--- + +## Related + +- Main README environment variable reference +- Codex + OpenCode ChatGPT auth (`OPENCODE_ENABLED`) is documented in [CODEX_SETUP.md](CODEX_SETUP.md) diff --git a/internal/agent/opencode_agent.go b/internal/agent/opencode_agent.go new file mode 100644 index 00000000..ea0175b8 --- /dev/null +++ b/internal/agent/opencode_agent.go @@ -0,0 +1,133 @@ +package agent + +import ( + "context" + "log/slog" + "time" + + "github.com/onllm-dev/onwatch/v2/internal/api" + "github.com/onllm-dev/onwatch/v2/internal/config" + "github.com/onllm-dev/onwatch/v2/internal/notify" + "github.com/onllm-dev/onwatch/v2/internal/store" + "github.com/onllm-dev/onwatch/v2/internal/tracker" +) + +type openCodeFetcher interface { + FetchSnapshot(ctx context.Context, workspaceID, authCookie string) (*api.OpenCodeSnapshot, error) +} + +type OpenCodeAgent struct { + client openCodeFetcher + store *store.Store + tracker *tracker.OpenCodeTracker + interval time.Duration + logger *slog.Logger + sm *SessionManager + notifier *notify.NotificationEngine + pollingCheck func() bool + cfg *config.Config +} + +func (a *OpenCodeAgent) SetPollingCheck(fn func() bool) { + a.pollingCheck = fn +} + +func (a *OpenCodeAgent) SetNotifier(n *notify.NotificationEngine) { + a.notifier = n +} + +func NewOpenCodeAgent(client openCodeFetcher, store *store.Store, tr *tracker.OpenCodeTracker, cfg *config.Config, interval time.Duration, logger *slog.Logger, sm *SessionManager) *OpenCodeAgent { + if logger == nil { + logger = slog.Default() + } + return &OpenCodeAgent{ + client: client, + store: store, + tracker: tr, + cfg: cfg, + interval: interval, + logger: logger, + sm: sm, + } +} + +func (a *OpenCodeAgent) Run(ctx context.Context) error { + a.logger.Info("OpenCode agent started", "interval", a.interval) + + defer func() { + if a.sm != nil { + a.sm.Close() + } + a.logger.Info("OpenCode agent stopped") + }() + + a.poll(ctx) + + ticker := time.NewTicker(a.interval) + defer ticker.Stop() + + for { + select { + case <-ticker.C: + a.poll(ctx) + case <-ctx.Done(): + return nil + } + } +} + +func (a *OpenCodeAgent) poll(ctx context.Context) { + if a.client == nil || a.cfg == nil { + return + } + if a.pollingCheck != nil && !a.pollingCheck() { + return + } + + workspaceID := a.cfg.OpenCodeGoWorkspaceID + authCookie := a.cfg.OpenCodeGoAuthCookie + + snapshot, err := a.client.FetchSnapshot(ctx, workspaceID, authCookie) + if err != nil { + if ctx.Err() != nil { + return + } + a.logger.Error("Failed to fetch OpenCode quotas", "error", err) + return + } + + if _, err := a.store.InsertOpenCodeSnapshot(snapshot); err != nil { + a.logger.Error("Failed to insert OpenCode snapshot", "error", err) + return + } + + if a.tracker != nil { + if err := a.tracker.Process(snapshot); err != nil { + a.logger.Error("OpenCode tracker processing failed", "error", err) + } + } + + if a.notifier != nil { + for _, q := range snapshot.Quotas { + a.notifier.Check(notify.QuotaStatus{ + Provider: "opencode", + QuotaKey: q.Name, + Utilization: q.Utilization, + Limit: q.Limit, + }) + } + } + + if a.sm != nil { + var values []float64 + for _, q := range snapshot.Quotas { + values = append(values, q.Utilization) + } + a.sm.ReportPoll(values) + } + + a.logger.Info("OpenCode poll complete", + "plan_name", snapshot.PlanName, + "quota_count", len(snapshot.Quotas), + ) +} diff --git a/internal/agent/opencode_agent_test.go b/internal/agent/opencode_agent_test.go new file mode 100644 index 00000000..8a3d427e --- /dev/null +++ b/internal/agent/opencode_agent_test.go @@ -0,0 +1,185 @@ +package agent + +import ( + "context" + "errors" + "log/slog" + "testing" + "time" + + "github.com/onllm-dev/onwatch/v2/internal/api" + "github.com/onllm-dev/onwatch/v2/internal/config" + "github.com/onllm-dev/onwatch/v2/internal/store" + "github.com/onllm-dev/onwatch/v2/internal/tracker" +) + +func TestNewOpenCodeAgent_Basic(t *testing.T) { + a := NewOpenCodeAgent(nil, nil, nil, nil, 60*time.Second, nil, nil) + if a == nil { + t.Fatal("nil agent") + } + a.SetPollingCheck(func() bool { return true }) + a.SetNotifier(nil) +} + +func TestOpenCodeAgent_Poll_NoClientSafe(t *testing.T) { + st, err := store.New(":memory:") + if err != nil { + t.Fatalf("store.New: %v", err) + } + defer st.Close() + + tr := tracker.NewOpenCodeTracker(st, nil) + ag := NewOpenCodeAgent(nil, st, tr, nil, time.Second, nil, NewSessionManager(st, "opencode", 60*time.Second, nil)) + ag.poll(context.Background()) +} + +func TestOpenCodeAgent_Poll_FetchErrorNoInsert(t *testing.T) { + st, err := store.New(":memory:") + if err != nil { + t.Fatalf("store.New: %v", err) + } + defer st.Close() + + cfg := &config.Config{ + OpenCodeGoWorkspaceID: "ws", + OpenCodeGoAuthCookie: "cookie", + } + client := api.NewOpenCodeClient(nil) + tr := tracker.NewOpenCodeTracker(st, nil) + ag := NewOpenCodeAgent(client, st, tr, cfg, time.Second, slog.Default(), NewSessionManager(st, "opencode", 60*time.Second, nil)) + + ag.poll(context.Background()) + + latest, err := st.QueryLatestOpenCode() + if err != nil { + t.Fatalf("QueryLatestOpenCode: %v", err) + } + if latest != nil { + t.Fatal("expected no snapshot after fetch failure") + } +} + +func TestOpenCodeAgent_Poll_SuccessInsertsAndTracks(t *testing.T) { + st, err := store.New(":memory:") + if err != nil { + t.Fatalf("store.New: %v", err) + } + defer st.Close() + + now := time.Now().UTC() + reset := now.Add(2 * time.Hour) + snapshot := &api.OpenCodeSnapshot{ + CapturedAt: now, + AccountType: api.OpenCodeAccountTypePro, + PlanName: "OpenCode Go", + Quotas: []api.OpenCodeQuota{ + {Name: "five_hour", Utilization: 10, Format: api.OpenCodeQuotaFormatPercent, ResetsAt: &reset}, + }, + } + + client := &stubOpenCodeClient{snapshot: snapshot} + tr := tracker.NewOpenCodeTracker(st, slog.Default()) + ag := NewOpenCodeAgent(client, st, tr, &config.Config{ + OpenCodeGoWorkspaceID: "ws", + OpenCodeGoAuthCookie: "cookie", + }, time.Second, slog.Default(), NewSessionManager(st, "opencode", 60*time.Second, nil)) + + ag.poll(context.Background()) + + latest, err := st.QueryLatestOpenCode() + if err != nil { + t.Fatalf("QueryLatestOpenCode: %v", err) + } + if latest == nil || len(latest.Quotas) != 1 { + t.Fatalf("expected inserted snapshot, got %+v", latest) + } + + cycle, err := st.QueryActiveOpenCodeCycle("five_hour") + if err != nil { + t.Fatalf("QueryActiveOpenCodeCycle: %v", err) + } + if cycle == nil { + t.Fatal("expected active cycle after tracker.Process") + } +} + +type stubOpenCodeClient struct { + snapshot *api.OpenCodeSnapshot + err error +} + +func (s *stubOpenCodeClient) FetchSnapshot(_ context.Context, _, _ string) (*api.OpenCodeSnapshot, error) { + if s.err != nil { + return nil, s.err + } + return s.snapshot, nil +} + +func TestOpenCodeAgent_Poll_MissingConfig(t *testing.T) { + st, err := store.New(":memory:") + if err != nil { + t.Fatalf("store.New: %v", err) + } + defer st.Close() + + client := api.NewOpenCodeClient(nil) + tr := tracker.NewOpenCodeTracker(st, nil) + ag := NewOpenCodeAgent(client, st, tr, &config.Config{}, time.Second, slog.Default(), nil) + + ag.poll(context.Background()) + + latest, err := st.QueryLatestOpenCode() + if err != nil { + t.Fatalf("QueryLatestOpenCode: %v", err) + } + if latest != nil { + t.Fatal("expected no snapshot when config missing") + } +} + +func TestOpenCodeAgent_Poll_AuthError(t *testing.T) { + st, err := store.New(":memory:") + if err != nil { + t.Fatalf("store.New: %v", err) + } + defer st.Close() + + client := &stubOpenCodeClient{err: api.ErrOpenCodeUnauthorized} + tr := tracker.NewOpenCodeTracker(st, nil) + ag := NewOpenCodeAgent(client, st, tr, &config.Config{ + OpenCodeGoWorkspaceID: "ws", + OpenCodeGoAuthCookie: "cookie", + }, time.Second, slog.Default(), nil) + + ag.poll(context.Background()) + + latest, err := st.QueryLatestOpenCode() + if err != nil { + t.Fatalf("QueryLatestOpenCode: %v", err) + } + if latest != nil { + t.Fatal("expected no snapshot on auth error") + } +} + +var _ interface { + FetchSnapshot(context.Context, string, string) (*api.OpenCodeSnapshot, error) +} = (*stubOpenCodeClient)(nil) + +func TestOpenCodeAgent_Poll_FetchErrorTyped(t *testing.T) { + st, err := store.New(":memory:") + if err != nil { + t.Fatalf("store.New: %v", err) + } + defer st.Close() + + client := &stubOpenCodeClient{err: errors.Join(api.ErrOpenCodeParseFailed, errors.New("details"))} + tr := tracker.NewOpenCodeTracker(st, nil) + ag := NewOpenCodeAgent(client, st, tr, &config.Config{ + OpenCodeGoWorkspaceID: "ws", + OpenCodeGoAuthCookie: "cookie", + }, time.Second, slog.Default(), nil) + + ag.poll(context.Background()) +} diff --git a/internal/api/opencode.go b/internal/api/opencode.go new file mode 100644 index 00000000..fa329749 --- /dev/null +++ b/internal/api/opencode.go @@ -0,0 +1,34 @@ +package api + +import "time" + +type OpenCodeQuotaFormat string + +const ( + OpenCodeQuotaFormatCurrency OpenCodeQuotaFormat = "currency" + OpenCodeQuotaFormatPercent OpenCodeQuotaFormat = "percent" +) + +type OpenCodeQuota struct { + Name string + Used float64 + Limit float64 + Utilization float64 + Format OpenCodeQuotaFormat + ResetsAt *time.Time +} + +type OpenCodeAccountType string + +const ( + OpenCodeAccountTypePro OpenCodeAccountType = "pro" +) + +type OpenCodeSnapshot struct { + ID int64 + CapturedAt time.Time + RawJSON string + AccountType OpenCodeAccountType + PlanName string + Quotas []OpenCodeQuota +} diff --git a/internal/api/opencode_client.go b/internal/api/opencode_client.go new file mode 100644 index 00000000..1c298bed --- /dev/null +++ b/internal/api/opencode_client.go @@ -0,0 +1,367 @@ +package api + +import ( + "context" + "errors" + "fmt" + "io" + "log/slog" + "net/http" + "net/url" + "regexp" + "strconv" + "strings" + "time" +) + +const ( + openCodeDashboardURLPrefix = "https://opencode.ai/workspace/" + openCodeDashboardURLSuffix = "/go" + openCodeUserAgent = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) Gecko/20100101 Firefox/148.0" + openCodeScrapeTimeout = 10 * time.Second + openCodeMaxBodyBytes = 2 << 20 // 2 MiB +) + +var ( + ErrOpenCodeUnauthorized = errors.New("opencode: unauthorized") + ErrOpenCodeForbidden = errors.New("opencode: forbidden") + ErrOpenCodeServerError = errors.New("opencode: server error") + ErrOpenCodeNetworkError = errors.New("opencode: network error") + ErrOpenCodeInvalidResponse = errors.New("opencode: invalid response") + ErrOpenCodeParseFailed = errors.New("opencode: parse failed") + ErrOpenCodeMissingConfig = errors.New("opencode: missing workspace id or auth cookie") +) + +type OpenCodeClient struct { + httpClient *http.Client + logger *slog.Logger + dashboardURLPrefix string +} + +type OpenCodeClientOption func(*OpenCodeClient) + +func WithOpenCodeHTTPTransport(rt http.RoundTripper) OpenCodeClientOption { + return func(c *OpenCodeClient) { + c.httpClient.Transport = rt + } +} + +func WithOpenCodeTimeout(timeout time.Duration) OpenCodeClientOption { + return func(c *OpenCodeClient) { + c.httpClient.Timeout = timeout + } +} + +func WithOpenCodeBaseURL(baseURL string) OpenCodeClientOption { + return func(c *OpenCodeClient) { + baseURL = strings.TrimRight(strings.TrimSpace(baseURL), "/") + c.dashboardURLPrefix = baseURL + "/workspace/" + } +} + +func NewOpenCodeClient(logger *slog.Logger, opts ...OpenCodeClientOption) *OpenCodeClient { + if logger == nil { + logger = slog.Default() + } + c := &OpenCodeClient{ + httpClient: &http.Client{ + Timeout: openCodeScrapeTimeout, + Transport: &http.Transport{ + MaxIdleConns: 1, + MaxIdleConnsPerHost: 1, + ResponseHeaderTimeout: openCodeScrapeTimeout, + IdleConnTimeout: 30 * time.Second, + TLSHandshakeTimeout: 10 * time.Second, + ForceAttemptHTTP2: true, + }, + }, + logger: logger, + dashboardURLPrefix: openCodeDashboardURLPrefix, + } + for _, o := range opts { + o(c) + } + return c +} + +type scrapedWindowUsage struct { + usagePercent float64 + resetInSec float64 +} + +func (c *OpenCodeClient) FetchSnapshot(ctx context.Context, workspaceID, authCookie string) (*OpenCodeSnapshot, error) { + workspaceID = strings.TrimSpace(workspaceID) + authCookie = strings.TrimSpace(authCookie) + if workspaceID == "" || authCookie == "" { + return nil, ErrOpenCodeMissingConfig + } + + capturedAt := time.Now().UTC() + html, err := c.fetchDashboardHTML(ctx, workspaceID, authCookie) + if err != nil { + return nil, err + } + + quotas, err := parseOpenCodeQuotas(html, capturedAt) + if err != nil { + return nil, err + } + + return &OpenCodeSnapshot{ + CapturedAt: capturedAt, + AccountType: OpenCodeAccountTypePro, + PlanName: "OpenCode Go", + Quotas: quotas, + }, nil +} + +func (c *OpenCodeClient) fetchDashboardHTML(ctx context.Context, workspaceID, authCookie string) (string, error) { + dashboardURL := c.dashboardURLPrefix + url.PathEscape(workspaceID) + openCodeDashboardURLSuffix + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, dashboardURL, nil) + if err != nil { + return "", fmt.Errorf("%w: build request: %v", ErrOpenCodeNetworkError, err) + } + req.Header.Set("User-Agent", openCodeUserAgent) + req.Header.Set("Accept", "text/html") + req.Header.Set("Cookie", openCodeAuthCookieHeader(authCookie)) + + resp, err := c.httpClient.Do(req) + if err != nil { + if ctx.Err() != nil { + return "", ctx.Err() + } + return "", fmt.Errorf("%w: %v", ErrOpenCodeNetworkError, err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(io.LimitReader(resp.Body, openCodeMaxBodyBytes)) + if err != nil { + return "", fmt.Errorf("%w: read body: %v", ErrOpenCodeNetworkError, err) + } + + switch resp.StatusCode { + case http.StatusOK: + return string(body), nil + case http.StatusUnauthorized: + return "", ErrOpenCodeUnauthorized + case http.StatusForbidden: + return "", ErrOpenCodeForbidden + default: + if resp.StatusCode >= 500 { + return "", fmt.Errorf("%w: http %d", ErrOpenCodeServerError, resp.StatusCode) + } + return "", fmt.Errorf("%w: http %d: %s", ErrOpenCodeInvalidResponse, resp.StatusCode, sanitizeOpenCodeMessage(string(body))) + } +} + +func openCodeAuthCookieHeader(authCookie string) string { + if strings.Contains(authCookie, "=") { + return authCookie + } + return "auth=" + authCookie +} + +func sanitizeOpenCodeMessage(text string) string { + s := strings.TrimSpace(text) + if s == "" { + return "unknown" + } + s = strings.Join(strings.Fields(s), " ") + if len(s) > 120 { + s = s[:120] + } + return s +} + +func parseOpenCodeQuotas(html string, capturedAt time.Time) ([]OpenCodeQuota, error) { + rolling := parseSSRWindowUsage(html, "rollingUsage") + weekly := parseSSRWindowUsage(html, "weeklyUsage") + monthly := parseSSRWindowUsage(html, "monthlyUsage") + + if rolling == nil && weekly == nil && monthly == nil { + dataSlot := parseDataSlotFormat(html) + rolling = dataSlot["rolling"] + weekly = dataSlot["weekly"] + monthly = dataSlot["monthly"] + } + + var quotas []OpenCodeQuota + if rolling != nil { + quotas = append(quotas, windowToQuota("five_hour", *rolling, capturedAt)) + } + if weekly != nil { + quotas = append(quotas, windowToQuota("weekly", *weekly, capturedAt)) + } + if monthly != nil { + quotas = append(quotas, windowToQuota("monthly", *monthly, capturedAt)) + } + + if len(quotas) == 0 { + return nil, fmt.Errorf("%w: could not parse rollingUsage, weeklyUsage, or monthlyUsage", ErrOpenCodeParseFailed) + } + return quotas, nil +} + +func windowToQuota(name string, window scrapedWindowUsage, capturedAt time.Time) OpenCodeQuota { + pct := window.usagePercent + if pct < 0 { + pct = 0 + } + resetSec := window.resetInSec + if resetSec < 0 { + resetSec = 0 + } + resetsAt := capturedAt.Add(time.Duration(resetSec) * time.Second) + return OpenCodeQuota{ + Name: name, + Used: pct, + Limit: 100, + Utilization: pct, + Format: OpenCodeQuotaFormatPercent, + ResetsAt: &resetsAt, + } +} + +var openCodeScrapedNumberPattern = `(-?\d+(?:\.\d+)?)` + +func parseSSRWindowUsage(html, prefix string) *scrapedWindowUsage { + pattern1 := prefix + `:\$R\[\d+\]=\{[^}]*usagePercent:` + openCodeScrapedNumberPattern + `[^}]*resetInSec:` + openCodeScrapedNumberPattern + pattern2 := prefix + `:\$R\[\d+\]=\{[^}]*resetInSec:` + openCodeScrapedNumberPattern + `[^}]*usagePercent:` + openCodeScrapedNumberPattern + + re1 := regexp.MustCompile(pattern1) + re2 := regexp.MustCompile(pattern2) + + if m := re1.FindStringSubmatch(html); len(m) >= 3 { + if pct, reset, ok := parseScrapedNumbers(m[1], m[2]); ok { + return &scrapedWindowUsage{usagePercent: pct, resetInSec: reset} + } + } + if m := re2.FindStringSubmatch(html); len(m) >= 3 { + if reset, pct, ok := parseScrapedNumbers(m[1], m[2]); ok { + return &scrapedWindowUsage{usagePercent: pct, resetInSec: reset} + } + } + return nil +} + +func parseScrapedNumbers(a, b string) (float64, float64, bool) { + first, err1 := strconv.ParseFloat(a, 64) + second, err2 := strconv.ParseFloat(b, 64) + if err1 != nil || err2 != nil { + return 0, 0, false + } + return first, second, true +} + +func parseDataSlotFormat(html string) map[string]*scrapedWindowUsage { + result := make(map[string]*scrapedWindowUsage) + parts := strings.Split(html, `data-slot="usage-item"`) + for i := 1; i < len(parts); i++ { + content := parts[i] + + labelRe := regexp.MustCompile(`data-slot="usage-label">([^<]+)<`) + labelMatch := labelRe.FindStringSubmatch(content) + if len(labelMatch) < 2 { + continue + } + label := strings.ToLower(strings.TrimSpace(labelMatch[1])) + + usageRe := regexp.MustCompile(`data-slot="usage-value">[^0-9]*(\d+(?:\.\d+)?)`) + usageMatch := usageRe.FindStringSubmatch(content) + if len(usageMatch) < 2 { + continue + } + usagePercent, err := strconv.ParseFloat(usageMatch[1], 64) + if err != nil { + continue + } + + resetRe := regexp.MustCompile(`data-slot="(reset-time|reset-now)">([\s\S]*?)`) + resetMatch := resetRe.FindStringSubmatch(content) + if len(resetMatch) < 3 { + continue + } + + var resetInSec float64 + if resetMatch[1] == "reset-now" { + resetInSec = 0 + } else { + resetContent := resetMatch[2] + resetContent = regexp.MustCompile(``).ReplaceAllString(resetContent, "") + resetContent = strings.TrimSpace(resetContent) + resetContent = regexp.MustCompile(`(?i)Resets?\s*in\s*`).ReplaceAllString(resetContent, "") + parsed, ok := parseHumanReadableTime(resetContent) + if !ok { + continue + } + resetInSec = parsed + } + + var windowKey string + switch { + case strings.Contains(label, "rolling"): + windowKey = "rolling" + case strings.Contains(label, "weekly"): + windowKey = "weekly" + case strings.Contains(label, "monthly"): + windowKey = "monthly" + default: + continue + } + + result[windowKey] = &scrapedWindowUsage{ + usagePercent: usagePercent, + resetInSec: resetInSec, + } + } + return result +} + +func parseHumanReadableTime(timeStr string) (float64, bool) { + normalized := strings.ToLower(strings.TrimSpace(timeStr)) + normalized = strings.Join(strings.Fields(normalized), " ") + switch normalized { + case "reset-now", "reset now", "now", "resets now": + return 0, true + } + + var totalSeconds float64 + hasDuration := false + + dayRe := regexp.MustCompile(`(\d+(?:\.\d+)?)\s*days?`) + hourRe := regexp.MustCompile(`(\d+(?:\.\d+)?)\s*hours?`) + minuteRe := regexp.MustCompile(`(\d+(?:\.\d+)?)\s*minutes?`) + secondRe := regexp.MustCompile(`(\d+(?:\.\d+)?)\s*seconds?`) + + if m := dayRe.FindStringSubmatch(normalized); len(m) >= 2 { + if v, err := strconv.ParseFloat(m[1], 64); err == nil { + totalSeconds += v * 86400 + hasDuration = true + } + } + if m := hourRe.FindStringSubmatch(normalized); len(m) >= 2 { + if v, err := strconv.ParseFloat(m[1], 64); err == nil { + totalSeconds += v * 3600 + hasDuration = true + } + } + if m := minuteRe.FindStringSubmatch(normalized); len(m) >= 2 { + if v, err := strconv.ParseFloat(m[1], 64); err == nil { + totalSeconds += v * 60 + hasDuration = true + } + } + if m := secondRe.FindStringSubmatch(normalized); len(m) >= 2 { + if v, err := strconv.ParseFloat(m[1], 64); err == nil { + totalSeconds += v + hasDuration = true + } + } + + return totalSeconds, hasDuration +} + +func IsOpenCodeAuthError(err error) bool { + return errors.Is(err, ErrOpenCodeUnauthorized) || errors.Is(err, ErrOpenCodeForbidden) +} diff --git a/internal/api/opencode_client_test.go b/internal/api/opencode_client_test.go new file mode 100644 index 00000000..a0c7934e --- /dev/null +++ b/internal/api/opencode_client_test.go @@ -0,0 +1,238 @@ +package api + +import ( + "context" + "errors" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" +) + +const ssrFixtureHTML = ` +rollingUsage:$R[123]={usagePercent:4.5,resetInSec:6000} +weeklyUsage:$R[124]={resetInSec:1209600,usagePercent:12.3} +monthlyUsage:$R[125]={usagePercent:25.0,resetInSec:2592000} +` + +const dataSlotFixtureHTML = ` +
+ Rolling Usage + 15% + Resets in 1 hour 30 minutes +
+
+ Weekly Usage + 22.5% + Reset now +
+
+ Monthly Usage + 40% + Resets in 6 days 2 hours +
+` + +func TestOpenCodeClient_FetchSnapshot_SSR(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/workspace/ws-123/go" { + t.Errorf("unexpected path: %s", r.URL.Path) + } + if cookie := r.Header.Get("Cookie"); cookie != "auth=secret-cookie" { + t.Errorf("unexpected cookie: %q", cookie) + } + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(ssrFixtureHTML)) + })) + defer srv.Close() + + client := newTestOpenCodeClient(t, srv) + snap, err := client.FetchSnapshot(context.Background(), "ws-123", "secret-cookie") + if err != nil { + t.Fatalf("FetchSnapshot: %v", err) + } + if len(snap.Quotas) != 3 { + t.Fatalf("quotas = %d, want 3", len(snap.Quotas)) + } + + byName := mapQuotasByName(snap.Quotas) + if byName["five_hour"].Utilization != 4.5 { + t.Errorf("five_hour util = %v, want 4.5", byName["five_hour"].Utilization) + } + if byName["weekly"].Utilization != 12.3 { + t.Errorf("weekly util = %v, want 12.3", byName["weekly"].Utilization) + } + if byName["monthly"].Utilization != 25.0 { + t.Errorf("monthly util = %v, want 25.0", byName["monthly"].Utilization) + } + for _, q := range snap.Quotas { + if q.Format != OpenCodeQuotaFormatPercent { + t.Errorf("quota %s format = %q, want percent", q.Name, q.Format) + } + if q.ResetsAt == nil { + t.Errorf("quota %s missing resetsAt", q.Name) + } + } +} + +func TestOpenCodeClient_FetchSnapshot_DataSlotFallback(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(dataSlotFixtureHTML)) + })) + defer srv.Close() + + client := newTestOpenCodeClient(t, srv) + snap, err := client.FetchSnapshot(context.Background(), "ws-abc", "tok") + if err != nil { + t.Fatalf("FetchSnapshot: %v", err) + } + if len(snap.Quotas) != 3 { + t.Fatalf("quotas = %d, want 3", len(snap.Quotas)) + } + byName := mapQuotasByName(snap.Quotas) + if byName["five_hour"].Utilization != 15 { + t.Errorf("five_hour util = %v, want 15", byName["five_hour"].Utilization) + } + if byName["weekly"].Utilization != 22.5 { + t.Errorf("weekly util = %v, want 22.5", byName["weekly"].Utilization) + } + if byName["monthly"].Utilization != 40 { + t.Errorf("monthly util = %v, want 40", byName["monthly"].Utilization) + } + + rollingReset := byName["five_hour"].ResetsAt.Sub(snap.CapturedAt) + if rollingReset < 89*time.Minute || rollingReset > 91*time.Minute { + t.Errorf("five_hour reset offset = %v, want ~90m", rollingReset) + } +} + +func TestOpenCodeClient_FetchSnapshot_401(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusUnauthorized) + _, _ = w.Write([]byte("login required secret")) + })) + defer srv.Close() + + client := newTestOpenCodeClient(t, srv) + _, err := client.FetchSnapshot(context.Background(), "ws", "cookie") + if !errors.Is(err, ErrOpenCodeUnauthorized) { + t.Fatalf("err = %v, want ErrOpenCodeUnauthorized", err) + } + if strings.Contains(err.Error(), "secret") { + t.Fatalf("error leaked response body: %v", err) + } +} + +func TestOpenCodeClient_FetchSnapshot_Malformed(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("no usage data")) + })) + defer srv.Close() + + client := newTestOpenCodeClient(t, srv) + _, err := client.FetchSnapshot(context.Background(), "ws", "cookie") + if !errors.Is(err, ErrOpenCodeParseFailed) { + t.Fatalf("err = %v, want ErrOpenCodeParseFailed", err) + } +} + +func TestOpenCodeClient_FetchSnapshot_MissingConfig(t *testing.T) { + client := NewOpenCodeClient(nil) + _, err := client.FetchSnapshot(context.Background(), "", "cookie") + if !errors.Is(err, ErrOpenCodeMissingConfig) { + t.Fatalf("empty workspace err = %v", err) + } + _, err = client.FetchSnapshot(context.Background(), "ws", "") + if !errors.Is(err, ErrOpenCodeMissingConfig) { + t.Fatalf("empty cookie err = %v", err) + } +} + +func TestOpenCodeClient_FetchSnapshot_CookieWithEquals(t *testing.T) { + var gotCookie string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotCookie = r.Header.Get("Cookie") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(ssrFixtureHTML)) + })) + defer srv.Close() + + client := newTestOpenCodeClient(t, srv) + _, err := client.FetchSnapshot(context.Background(), "ws", "auth=already-set") + if err != nil { + t.Fatalf("FetchSnapshot: %v", err) + } + if gotCookie != "auth=already-set" { + t.Errorf("cookie = %q, want auth=already-set", gotCookie) + } +} + +func TestOpenCodeClient_FetchSnapshot_WorkspaceURLEncoded(t *testing.T) { + var gotRequestURI string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotRequestURI = r.RequestURI + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(ssrFixtureHTML)) + })) + defer srv.Close() + + client := newTestOpenCodeClient(t, srv) + _, err := client.FetchSnapshot(context.Background(), "ws/special id", "cookie") + if err != nil { + t.Fatalf("FetchSnapshot: %v", err) + } + if !strings.Contains(gotRequestURI, "ws%2Fspecial%20id") { + t.Errorf("request URI = %q, want encoded workspace id", gotRequestURI) + } +} + +func TestParseHumanReadableTime(t *testing.T) { + tests := []struct { + in string + want float64 + ok bool + }{ + {"1 hour 56 minutes", 6960, true}, + {"6 days 2 hours", 525600, true}, + {"reset now", 0, true}, + {"not a duration", 0, false}, + } + for _, tc := range tests { + got, ok := parseHumanReadableTime(tc.in) + if ok != tc.ok { + t.Errorf("parseHumanReadableTime(%q) ok = %v, want %v", tc.in, ok, tc.ok) + continue + } + if ok && got != tc.want { + t.Errorf("parseHumanReadableTime(%q) = %v, want %v", tc.in, got, tc.want) + } + } +} + +func TestIsOpenCodeAuthError(t *testing.T) { + if !IsOpenCodeAuthError(ErrOpenCodeUnauthorized) { + t.Error("expected unauthorized") + } + if !IsOpenCodeAuthError(ErrOpenCodeForbidden) { + t.Error("expected forbidden") + } + if IsOpenCodeAuthError(ErrOpenCodeParseFailed) { + t.Error("parse failed should not be auth error") + } +} + +func newTestOpenCodeClient(t *testing.T, srv *httptest.Server) *OpenCodeClient { + t.Helper() + return NewOpenCodeClient(nil, WithOpenCodeBaseURL(srv.URL)) +} + +func mapQuotasByName(quotas []OpenCodeQuota) map[string]OpenCodeQuota { + out := make(map[string]OpenCodeQuota, len(quotas)) + for _, q := range quotas { + out[q.Name] = q + } + return out +} diff --git a/internal/config/config.go b/internal/config/config.go index 9205e166..cd2064ec 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -43,6 +43,9 @@ type Config struct { CodexAutoSource string // "codex" | "opencode" when auto-detected (display/logging) CodexHasProfiles bool // true if saved profiles exist (enables bootstrap without token) OpenCodeEnabled bool // OPENCODE_ENABLED=true: track ChatGPT via OpenCode auth.json (feeds Codex) + // OpenCode Go provider configuration + OpenCodeGoWorkspaceID string // OPENCODE_GO_WORKSPACE_ID + OpenCodeGoAuthCookie string // OPENCODE_GO_AUTH_COOKIE CodexShowAvailable string // CODEX_SHOW_AVAILABLE: "usage" | "available", default "usage" (Codex-specific override) CodexAutoStart5h bool // CODEX_AUTO_START_5H: auto-send a starter ping when the 5h window resets (Beta, default off) CodexAutoStart7d bool // CODEX_AUTO_START_7D: auto-send a starter ping when the weekly window resets (Beta, default off) @@ -218,6 +221,8 @@ var onwatchEnvKeys = []string{ "COPILOT_TOKEN", "CODEX_TOKEN", "OPENCODE_ENABLED", + "OPENCODE_GO_WORKSPACE_ID", + "OPENCODE_GO_AUTH_COOKIE", "OPENCODE_HOME", "ANTIGRAVITY_ENABLED", "MINIMAX_API_KEY", @@ -329,6 +334,8 @@ func loadFromEnvAndFlags(flags *flagValues) (*Config, error) { } // OpenCode feeds the Codex provider using ChatGPT OAuth stored by OpenCode. cfg.OpenCodeEnabled = os.Getenv("OPENCODE_ENABLED") == "true" + cfg.OpenCodeGoWorkspaceID = strings.TrimSpace(os.Getenv("OPENCODE_GO_WORKSPACE_ID")) + cfg.OpenCodeGoAuthCookie = strings.TrimSpace(os.Getenv("OPENCODE_GO_AUTH_COOKIE")) // Codex auto quota-starter (Beta): default off; the dashboard toggle in // provider_settings overrides these env-provided defaults at runtime. cfg.CodexAutoStart5h = os.Getenv("CODEX_AUTO_START_5H") == "true" @@ -647,6 +654,9 @@ func (c *Config) AvailableProviders() []string { if c.KimiToken != "" || c.KimiEnabled { providers = append(providers, "kimi") } + if c.OpenCodeGoWorkspaceID != "" && c.OpenCodeGoAuthCookie != "" { + providers = append(providers, "opencode") + } return providers } @@ -681,6 +691,8 @@ func (c *Config) HasProvider(name string) bool { return c.GrokToken != "" || c.GrokEnabled case "kimi": return c.KimiToken != "" || c.KimiEnabled + case "opencode": + return c.OpenCodeGoWorkspaceID != "" && c.OpenCodeGoAuthCookie != "" } return false } @@ -730,6 +742,9 @@ func (c *Config) HasMultipleProviders() bool { if c.KimiToken != "" || c.KimiEnabled { count++ } + if c.OpenCodeGoWorkspaceID != "" && c.OpenCodeGoAuthCookie != "" { + count++ + } return count > 1 } @@ -802,6 +817,9 @@ func (c *Config) String() string { // Redact Kimi token kimiDisplay := redactAPIKey(c.KimiToken, "") fmt.Fprintf(&sb, " KimiToken: %s,\n", kimiDisplay) + opencodeDisplay := redactAPIKey(c.OpenCodeGoAuthCookie, "") + fmt.Fprintf(&sb, " OpenCodeGoWorkspaceID: %s,\n", c.OpenCodeGoWorkspaceID) + fmt.Fprintf(&sb, " OpenCodeGoAuthCookie: %s,\n", opencodeDisplay) if c.KimiAutoToken { fmt.Fprintf(&sb, " KimiAutoToken: true,\n") } diff --git a/internal/store/opencode_store.go b/internal/store/opencode_store.go new file mode 100644 index 00000000..786a85c6 --- /dev/null +++ b/internal/store/opencode_store.go @@ -0,0 +1,523 @@ +package store + +import ( + "database/sql" + "fmt" + "time" + + "github.com/onllm-dev/onwatch/v2/internal/api" +) + +type OpenCodeResetCycle struct { + ID int64 + QuotaName string + CycleStart time.Time + CycleEnd *time.Time + ResetsAt *time.Time + PeakUtilization float64 + TotalDelta float64 +} + +type OpenCodeLatestQuota struct { + Name string + Used float64 + Limit float64 + Utilization float64 + Format string + ResetsAt *time.Time + CapturedAt time.Time + AccountType string + PlanName string +} + +func (s *Store) InsertOpenCodeSnapshot(snapshot *api.OpenCodeSnapshot) (int64, error) { + tx, err := s.db.Begin() + if err != nil { + return 0, fmt.Errorf("failed to begin transaction: %w", err) + } + defer tx.Rollback() + + result, err := tx.Exec( + `INSERT INTO opencode_snapshots (captured_at, raw_json, account_type, plan_name, quota_count) VALUES (?, ?, ?, ?, ?)`, + snapshot.CapturedAt.Format(time.RFC3339Nano), + snapshot.RawJSON, + string(snapshot.AccountType), + snapshot.PlanName, + len(snapshot.Quotas), + ) + if err != nil { + return 0, fmt.Errorf("failed to insert opencode snapshot: %w", err) + } + + snapshotID, err := result.LastInsertId() + if err != nil { + return 0, fmt.Errorf("failed to get snapshot ID: %w", err) + } + + for _, q := range snapshot.Quotas { + var resetsAt interface{} + if q.ResetsAt != nil { + resetsAt = q.ResetsAt.Format(time.RFC3339Nano) + } + _, err := tx.Exec( + `INSERT INTO opencode_quota_values (snapshot_id, quota_name, used, limit_value, utilization, format, resets_at) VALUES (?, ?, ?, ?, ?, ?, ?)`, + snapshotID, q.Name, q.Used, q.Limit, q.Utilization, string(q.Format), resetsAt, + ) + if err != nil { + return 0, fmt.Errorf("failed to insert quota value %s: %w", q.Name, err) + } + } + + if err := tx.Commit(); err != nil { + return 0, fmt.Errorf("failed to commit: %w", err) + } + + return snapshotID, nil +} + +func (s *Store) QueryLatestOpenCode() (*api.OpenCodeSnapshot, error) { + var snapshot api.OpenCodeSnapshot + var capturedAt, accountType, planName string + + err := s.db.QueryRow( + `SELECT id, captured_at, account_type, plan_name, quota_count FROM opencode_snapshots ORDER BY captured_at DESC LIMIT 1`, + ).Scan(&snapshot.ID, &capturedAt, &accountType, &planName, new(int)) + + if err == sql.ErrNoRows { + return nil, nil + } + if err != nil { + return nil, fmt.Errorf("failed to query latest opencode: %w", err) + } + + snapshot.CapturedAt, _ = time.Parse(time.RFC3339Nano, capturedAt) + snapshot.AccountType = api.OpenCodeAccountType(accountType) + snapshot.PlanName = planName + + rows, err := s.db.Query( + `SELECT quota_name, used, limit_value, utilization, format, resets_at FROM opencode_quota_values WHERE snapshot_id = ? ORDER BY quota_name`, + snapshot.ID, + ) + if err != nil { + return nil, fmt.Errorf("failed to query quota values: %w", err) + } + defer rows.Close() + + for rows.Next() { + var q api.OpenCodeQuota + var format string + var resetsAt sql.NullString + if err := rows.Scan(&q.Name, &q.Used, &q.Limit, &q.Utilization, &format, &resetsAt); err != nil { + return nil, fmt.Errorf("failed to scan quota value: %w", err) + } + q.Format = api.OpenCodeQuotaFormat(format) + if resetsAt.Valid && resetsAt.String != "" { + t, _ := time.Parse(time.RFC3339Nano, resetsAt.String) + q.ResetsAt = &t + } + snapshot.Quotas = append(snapshot.Quotas, q) + } + + return &snapshot, rows.Err() +} + +func (s *Store) QueryOpenCodeRange(start, end time.Time, limit ...int) ([]*api.OpenCodeSnapshot, error) { + query := `SELECT id, captured_at, account_type, plan_name, quota_count FROM opencode_snapshots + WHERE captured_at BETWEEN ? AND ? ORDER BY captured_at ASC` + args := []interface{}{start.UTC().Format(time.RFC3339Nano), end.UTC().Format(time.RFC3339Nano)} + if len(limit) > 0 && limit[0] > 0 { + query = `SELECT id, captured_at, account_type, plan_name, quota_count + FROM ( + SELECT id, captured_at, account_type, plan_name, quota_count + FROM opencode_snapshots + WHERE captured_at BETWEEN ? AND ? + ORDER BY captured_at DESC + LIMIT ? + ) recent + ORDER BY captured_at ASC` + args = append(args, limit[0]) + } + + rows, err := s.db.Query(query, args...) + if err != nil { + return nil, fmt.Errorf("failed to query opencode range: %w", err) + } + defer rows.Close() + + var snapshots []*api.OpenCodeSnapshot + for rows.Next() { + var snap api.OpenCodeSnapshot + var capturedAt, accountType, planName string + if err := rows.Scan(&snap.ID, &capturedAt, &accountType, &planName, new(int)); err != nil { + return nil, fmt.Errorf("failed to scan opencode snapshot: %w", err) + } + snap.CapturedAt, _ = time.Parse(time.RFC3339Nano, capturedAt) + snap.AccountType = api.OpenCodeAccountType(accountType) + snap.PlanName = planName + snapshots = append(snapshots, &snap) + } + + if err := rows.Err(); err != nil { + return nil, err + } + + for _, snap := range snapshots { + qRows, err := s.db.Query( + `SELECT quota_name, used, limit_value, utilization, format, resets_at FROM opencode_quota_values WHERE snapshot_id = ? ORDER BY quota_name`, + snap.ID, + ) + if err != nil { + return nil, fmt.Errorf("failed to query quota values for snapshot %d: %w", snap.ID, err) + } + for qRows.Next() { + var q api.OpenCodeQuota + var format string + var resetsAt sql.NullString + if err := qRows.Scan(&q.Name, &q.Used, &q.Limit, &q.Utilization, &format, &resetsAt); err != nil { + qRows.Close() + return nil, fmt.Errorf("failed to scan quota value: %w", err) + } + q.Format = api.OpenCodeQuotaFormat(format) + if resetsAt.Valid && resetsAt.String != "" { + t, _ := time.Parse(time.RFC3339Nano, resetsAt.String) + q.ResetsAt = &t + } + snap.Quotas = append(snap.Quotas, q) + } + qRows.Close() + } + + return snapshots, nil +} + +func (s *Store) CreateOpenCodeCycle(quotaName string, cycleStart time.Time, resetsAt *time.Time) (int64, error) { + var resetsAtVal interface{} + if resetsAt != nil { + resetsAtVal = resetsAt.Format(time.RFC3339Nano) + } + + result, err := s.db.Exec( + `INSERT INTO opencode_reset_cycles (quota_name, cycle_start, resets_at) VALUES (?, ?, ?)`, + quotaName, cycleStart.Format(time.RFC3339Nano), resetsAtVal, + ) + if err != nil { + return 0, fmt.Errorf("failed to create opencode cycle: %w", err) + } + + id, err := result.LastInsertId() + if err != nil { + return 0, fmt.Errorf("failed to get cycle ID: %w", err) + } + return id, nil +} + +func (s *Store) CloseOpenCodeCycle(quotaName string, cycleEnd time.Time, peak, delta float64) error { + _, err := s.db.Exec( + `UPDATE opencode_reset_cycles SET cycle_end = ?, peak_utilization = ?, total_delta = ? + WHERE quota_name = ? AND cycle_end IS NULL`, + cycleEnd.Format(time.RFC3339Nano), peak, delta, quotaName, + ) + if err != nil { + return fmt.Errorf("failed to close opencode cycle: %w", err) + } + return nil +} + +func (s *Store) UpdateOpenCodeCycle(quotaName string, peak, delta float64) error { + _, err := s.db.Exec( + `UPDATE opencode_reset_cycles SET peak_utilization = ?, total_delta = ? + WHERE quota_name = ? AND cycle_end IS NULL`, + peak, delta, quotaName, + ) + if err != nil { + return fmt.Errorf("failed to update opencode cycle: %w", err) + } + return nil +} + +func (s *Store) QueryActiveOpenCodeCycle(quotaName string) (*OpenCodeResetCycle, error) { + var cycle OpenCodeResetCycle + var cycleStart string + var cycleEnd, resetsAt sql.NullString + + err := s.db.QueryRow( + `SELECT id, quota_name, cycle_start, cycle_end, resets_at, peak_utilization, total_delta + FROM opencode_reset_cycles WHERE quota_name = ? AND cycle_end IS NULL`, + quotaName, + ).Scan( + &cycle.ID, &cycle.QuotaName, &cycleStart, &cycleEnd, &resetsAt, + &cycle.PeakUtilization, &cycle.TotalDelta, + ) + + if err == sql.ErrNoRows { + return nil, nil + } + if err != nil { + return nil, fmt.Errorf("failed to query active opencode cycle: %w", err) + } + + cycle.CycleStart, _ = time.Parse(time.RFC3339Nano, cycleStart) + if cycleEnd.Valid { + t, _ := time.Parse(time.RFC3339Nano, cycleEnd.String) + cycle.CycleEnd = &t + } + if resetsAt.Valid { + t, _ := time.Parse(time.RFC3339Nano, resetsAt.String) + cycle.ResetsAt = &t + } + + return &cycle, nil +} + +func (s *Store) QueryOpenCodeCycleHistory(quotaName string, limit ...int) ([]*OpenCodeResetCycle, error) { + query := `SELECT id, quota_name, cycle_start, cycle_end, resets_at, peak_utilization, total_delta + FROM opencode_reset_cycles WHERE quota_name = ? AND cycle_end IS NOT NULL ORDER BY cycle_start DESC` + args := []interface{}{quotaName} + if len(limit) > 0 && limit[0] > 0 { + query += ` LIMIT ?` + args = append(args, limit[0]) + } + + rows, err := s.db.Query(query, args...) + if err != nil { + return nil, fmt.Errorf("failed to query opencode cycles: %w", err) + } + defer rows.Close() + + var cycles []*OpenCodeResetCycle + for rows.Next() { + var cycle OpenCodeResetCycle + var cycleStart, cycleEnd string + var resetsAt sql.NullString + + if err := rows.Scan(&cycle.ID, &cycle.QuotaName, &cycleStart, &cycleEnd, &resetsAt, + &cycle.PeakUtilization, &cycle.TotalDelta); err != nil { + return nil, fmt.Errorf("failed to scan opencode cycle: %w", err) + } + + cycle.CycleStart, _ = time.Parse(time.RFC3339Nano, cycleStart) + t, _ := time.Parse(time.RFC3339Nano, cycleEnd) + cycle.CycleEnd = &t + if resetsAt.Valid { + rt, _ := time.Parse(time.RFC3339Nano, resetsAt.String) + cycle.ResetsAt = &rt + } + + cycles = append(cycles, &cycle) + } + + return cycles, rows.Err() +} + +func (s *Store) QueryOpenCodeCyclesSince(quotaName string, since time.Time) ([]*OpenCodeResetCycle, error) { + rows, err := s.db.Query( + `SELECT id, quota_name, cycle_start, cycle_end, resets_at, peak_utilization, total_delta + FROM opencode_reset_cycles WHERE quota_name = ? AND cycle_end IS NOT NULL AND cycle_start >= ? + ORDER BY cycle_start DESC`, + quotaName, since.UTC().Format(time.RFC3339Nano), + ) + if err != nil { + return nil, fmt.Errorf("failed to query opencode cycles since: %w", err) + } + defer rows.Close() + + var cycles []*OpenCodeResetCycle + for rows.Next() { + var cycle OpenCodeResetCycle + var cycleStart, cycleEnd string + var resetsAt sql.NullString + + if err := rows.Scan(&cycle.ID, &cycle.QuotaName, &cycleStart, &cycleEnd, &resetsAt, + &cycle.PeakUtilization, &cycle.TotalDelta); err != nil { + return nil, fmt.Errorf("failed to scan opencode cycle: %w", err) + } + + cycle.CycleStart, _ = time.Parse(time.RFC3339Nano, cycleStart) + t, _ := time.Parse(time.RFC3339Nano, cycleEnd) + cycle.CycleEnd = &t + if resetsAt.Valid { + rt, _ := time.Parse(time.RFC3339Nano, resetsAt.String) + cycle.ResetsAt = &rt + } + + cycles = append(cycles, &cycle) + } + + return cycles, rows.Err() +} + +func (s *Store) QueryOpenCodeUtilizationSeries(quotaName string, since time.Time) ([]UtilizationPoint, error) { + rows, err := s.db.Query( + `SELECT s.captured_at, qv.utilization + FROM opencode_quota_values qv + JOIN opencode_snapshots s ON s.id = qv.snapshot_id + WHERE qv.quota_name = ? AND s.captured_at >= ? + ORDER BY s.captured_at ASC`, + quotaName, since.UTC().Format(time.RFC3339Nano), + ) + if err != nil { + return nil, fmt.Errorf("failed to query utilization series: %w", err) + } + defer rows.Close() + + var points []UtilizationPoint + for rows.Next() { + var capturedAt string + var util float64 + if err := rows.Scan(&capturedAt, &util); err != nil { + return nil, fmt.Errorf("failed to scan utilization point: %w", err) + } + t, _ := time.Parse(time.RFC3339Nano, capturedAt) + points = append(points, UtilizationPoint{CapturedAt: t, Utilization: util}) + } + + return points, rows.Err() +} + +func (s *Store) QueryOpenCodeLatestPerQuota() ([]OpenCodeLatestQuota, error) { + rows, err := s.db.Query(` + SELECT qv.quota_name, qv.used, qv.limit_value, qv.utilization, qv.format, qv.resets_at, + s.captured_at, s.account_type, s.plan_name + FROM opencode_quota_values qv + JOIN opencode_snapshots s ON s.id = qv.snapshot_id + WHERE s.id = (SELECT MAX(id) FROM opencode_snapshots) + ORDER BY qv.quota_name ASC`) + if err != nil { + return nil, fmt.Errorf("failed to query latest per-quota: %w", err) + } + defer rows.Close() + + var results []OpenCodeLatestQuota + for rows.Next() { + var name, format, accountType, planName string + var used, limitValue, utilization float64 + var resetsAt sql.NullString + var capturedAt string + + if err := rows.Scan(&name, &used, &limitValue, &utilization, &format, &resetsAt, &capturedAt, &accountType, &planName); err != nil { + return nil, fmt.Errorf("failed to scan latest quota: %w", err) + } + + q := OpenCodeLatestQuota{ + Name: name, + Used: used, + Limit: limitValue, + Utilization: utilization, + Format: format, + AccountType: accountType, + PlanName: planName, + } + q.CapturedAt, _ = time.Parse(time.RFC3339Nano, capturedAt) + if resetsAt.Valid && resetsAt.String != "" { + t, _ := time.Parse(time.RFC3339Nano, resetsAt.String) + q.ResetsAt = &t + } + results = append(results, q) + } + return results, rows.Err() +} + +func (s *Store) QueryAllOpenCodeQuotaNames() ([]string, error) { + rows, err := s.db.Query( + `SELECT DISTINCT quota_name FROM opencode_reset_cycles ORDER BY quota_name`, + ) + if err != nil { + return nil, fmt.Errorf("failed to query opencode quota names: %w", err) + } + defer rows.Close() + + var names []string + for rows.Next() { + var name string + if err := rows.Scan(&name); err != nil { + return nil, fmt.Errorf("failed to scan quota name: %w", err) + } + names = append(names, name) + } + + return names, rows.Err() +} + +func (s *Store) QueryOpenCodeCycleOverview(groupBy string, limit int) ([]CycleOverviewRow, error) { + if limit <= 0 { + limit = 50 + } + + var cycles []*OpenCodeResetCycle + activeCycle, err := s.QueryActiveOpenCodeCycle(groupBy) + if err != nil { + return nil, fmt.Errorf("store.QueryOpenCodeCycleOverview: active: %w", err) + } + if activeCycle != nil { + cycles = append(cycles, activeCycle) + limit-- + } + + completedCycles, err := s.QueryOpenCodeCycleHistory(groupBy, limit) + if err != nil { + return nil, fmt.Errorf("store.QueryOpenCodeCycleOverview: %w", err) + } + cycles = append(cycles, completedCycles...) + + var overviewRows []CycleOverviewRow + for _, c := range cycles { + row := CycleOverviewRow{ + CycleID: c.ID, + QuotaType: c.QuotaName, + CycleStart: c.CycleStart, + CycleEnd: c.CycleEnd, + PeakValue: c.PeakUtilization, + TotalDelta: c.TotalDelta, + } + + var endBoundary time.Time + if c.CycleEnd != nil { + endBoundary = *c.CycleEnd + } else { + endBoundary = time.Now().Add(time.Minute) + } + + var snapshotID int64 + var capturedAt string + err := s.db.QueryRow( + `SELECT s.id, s.captured_at FROM opencode_snapshots s + JOIN opencode_quota_values qv ON qv.snapshot_id = s.id + WHERE qv.quota_name = ? AND s.captured_at >= ? AND s.captured_at < ? + ORDER BY qv.utilization DESC LIMIT 1`, + groupBy, + c.CycleStart.Format(time.RFC3339Nano), + endBoundary.Format(time.RFC3339Nano), + ).Scan(&snapshotID, &capturedAt) + + if err == sql.ErrNoRows { + overviewRows = append(overviewRows, row) + continue + } + if err != nil { + return nil, fmt.Errorf("store.QueryOpenCodeCycleOverview: peak snapshot: %w", err) + } + + row.PeakTime, _ = time.Parse(time.RFC3339Nano, capturedAt) + + qRows, err := s.db.Query( + `SELECT quota_name, utilization, used, limit_value FROM opencode_quota_values WHERE snapshot_id = ? ORDER BY quota_name`, + snapshotID, + ) + if err != nil { + return nil, fmt.Errorf("store.QueryOpenCodeCycleOverview: quota values: %w", err) + } + for qRows.Next() { + var entry CrossQuotaEntry + if err := qRows.Scan(&entry.Name, &entry.Percent, &entry.Value, new(float64)); err != nil { + qRows.Close() + return nil, fmt.Errorf("store.QueryOpenCodeCycleOverview: scan quota: %w", err) + } + row.CrossQuotas = append(row.CrossQuotas, entry) + } + qRows.Close() + + overviewRows = append(overviewRows, row) + } + + return overviewRows, nil +} diff --git a/internal/store/opencode_store_test.go b/internal/store/opencode_store_test.go new file mode 100644 index 00000000..a706d48b --- /dev/null +++ b/internal/store/opencode_store_test.go @@ -0,0 +1,116 @@ +package store + +import ( + "testing" + "time" + + "github.com/onllm-dev/onwatch/v2/internal/api" +) + +func TestOpenCodeStore_InsertAndQueryLatest(t *testing.T) { + s, err := New(":memory:") + if err != nil { + t.Fatalf("new store: %v", err) + } + defer s.Close() + + now := time.Now().UTC() + reset := now.Add(5 * time.Hour) + snap := &api.OpenCodeSnapshot{ + CapturedAt: now, + AccountType: api.OpenCodeAccountTypePro, + PlanName: "OpenCode Go", + Quotas: []api.OpenCodeQuota{ + {Name: "five_hour", Used: 10, Limit: 100, Utilization: 10, Format: api.OpenCodeQuotaFormatPercent, ResetsAt: &reset}, + {Name: "weekly", Used: 20, Limit: 100, Utilization: 20, Format: api.OpenCodeQuotaFormatPercent, ResetsAt: &reset}, + }, + } + id, err := s.InsertOpenCodeSnapshot(snap) + if err != nil { + t.Fatalf("insert: %v", err) + } + if id == 0 { + t.Error("expected id > 0") + } + + latest, err := s.QueryLatestOpenCode() + if err != nil { + t.Fatalf("query latest: %v", err) + } + if latest == nil || latest.PlanName != "OpenCode Go" || len(latest.Quotas) != 2 { + t.Errorf("latest mismatch: %+v", latest) + } +} + +func TestOpenCodeStore_QueryRangeLoadsQuotas(t *testing.T) { + s, err := New(":memory:") + if err != nil { + t.Fatalf("new store: %v", err) + } + defer s.Close() + + base := time.Now().UTC().Add(-time.Hour) + for i := 0; i < 3; i++ { + snap := &api.OpenCodeSnapshot{ + CapturedAt: base.Add(time.Duration(i) * time.Minute), + Quotas: []api.OpenCodeQuota{ + {Name: "five_hour", Utilization: float64(i+1)*10, Format: api.OpenCodeQuotaFormatPercent}, + }, + } + if _, err := s.InsertOpenCodeSnapshot(snap); err != nil { + t.Fatalf("insert %d: %v", i, err) + } + } + + snaps, err := s.QueryOpenCodeRange(base.Add(-time.Minute), time.Now().UTC()) + if err != nil { + t.Fatalf("query range: %v", err) + } + if len(snaps) != 3 { + t.Fatalf("snapshots = %d, want 3", len(snaps)) + } + if len(snaps[0].Quotas) != 1 { + t.Fatalf("first snapshot quotas = %d, want 1", len(snaps[0].Quotas)) + } +} + +func TestOpenCodeStore_CycleLifecycle(t *testing.T) { + s, err := New(":memory:") + if err != nil { + t.Fatalf("new store: %v", err) + } + defer s.Close() + + now := time.Now().UTC() + reset := now.Add(5 * time.Hour) + id, err := s.CreateOpenCodeCycle("five_hour", now, &reset) + if err != nil { + t.Fatalf("CreateOpenCodeCycle: %v", err) + } + if id == 0 { + t.Fatal("expected cycle id") + } + + active, err := s.QueryActiveOpenCodeCycle("five_hour") + if err != nil { + t.Fatalf("QueryActiveOpenCodeCycle: %v", err) + } + if active == nil || active.QuotaName != "five_hour" { + t.Fatalf("active cycle mismatch: %+v", active) + } + + if err := s.UpdateOpenCodeCycle("five_hour", 15, 5); err != nil { + t.Fatalf("UpdateOpenCodeCycle: %v", err) + } + if err := s.CloseOpenCodeCycle("five_hour", now.Add(time.Hour), 15, 5); err != nil { + t.Fatalf("CloseOpenCodeCycle: %v", err) + } + + history, err := s.QueryOpenCodeCycleHistory("five_hour") + if err != nil { + t.Fatalf("QueryOpenCodeCycleHistory: %v", err) + } + if len(history) != 1 { + t.Fatalf("history = %d, want 1", len(history)) + } +} diff --git a/internal/store/store.go b/internal/store/store.go index 68bf4b00..1a136143 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -778,6 +778,43 @@ func (s *Store) createTables() error { CREATE INDEX IF NOT EXISTS idx_kimi_cycles_name_active ON kimi_reset_cycles(quota_name, cycle_end) WHERE cycle_end IS NULL; CREATE INDEX IF NOT EXISTS idx_kimi_snapshots_account ON kimi_snapshots(account_id, captured_at); + -- OpenCode Go tables + CREATE TABLE IF NOT EXISTS opencode_snapshots ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + captured_at TEXT NOT NULL, + raw_json TEXT NOT NULL DEFAULT '', + account_type TEXT NOT NULL DEFAULT '', + plan_name TEXT NOT NULL DEFAULT '', + quota_count INTEGER NOT NULL DEFAULT 0 + ); + + CREATE TABLE IF NOT EXISTS opencode_quota_values ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + snapshot_id INTEGER NOT NULL, + quota_name TEXT NOT NULL, + used REAL NOT NULL DEFAULT 0, + limit_value REAL NOT NULL DEFAULT 0, + utilization REAL NOT NULL DEFAULT 0, + format TEXT NOT NULL DEFAULT 'percent', + resets_at TEXT, + FOREIGN KEY (snapshot_id) REFERENCES opencode_snapshots(id) + ); + + CREATE TABLE IF NOT EXISTS opencode_reset_cycles ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + quota_name TEXT NOT NULL, + cycle_start TEXT NOT NULL, + cycle_end TEXT, + resets_at TEXT, + peak_utilization REAL NOT NULL DEFAULT 0, + total_delta REAL NOT NULL DEFAULT 0 + ); + + CREATE INDEX IF NOT EXISTS idx_opencode_snapshots_captured ON opencode_snapshots(captured_at); + CREATE INDEX IF NOT EXISTS idx_opencode_quota_values_snapshot ON opencode_quota_values(snapshot_id); + CREATE INDEX IF NOT EXISTS idx_opencode_cycles_name_start ON opencode_reset_cycles(quota_name, cycle_start); + CREATE INDEX IF NOT EXISTS idx_opencode_cycles_name_active ON opencode_reset_cycles(quota_name, cycle_end) WHERE cycle_end IS NULL; + -- API integrations telemetry ingestion tables CREATE TABLE IF NOT EXISTS api_integration_usage_events ( id INTEGER PRIMARY KEY AUTOINCREMENT, diff --git a/internal/tracker/opencode_tracker.go b/internal/tracker/opencode_tracker.go new file mode 100644 index 00000000..d6d8bb53 --- /dev/null +++ b/internal/tracker/opencode_tracker.go @@ -0,0 +1,272 @@ +package tracker + +import ( + "fmt" + "log/slog" + "time" + + "github.com/onllm-dev/onwatch/v2/internal/api" + "github.com/onllm-dev/onwatch/v2/internal/store" +) + +type OpenCodeTracker struct { + store *store.Store + logger *slog.Logger + lastValues map[string]float64 + lastResets map[string]string + hasLast bool + onReset func(quotaName string) +} + +func (t *OpenCodeTracker) SetOnReset(fn func(string)) { + t.onReset = fn +} + +type OpenCodeSummary struct { + QuotaName string + CurrentUtil float64 + ResetsAt *time.Time + TimeUntilReset time.Duration + CurrentRate float64 + ProjectedUtil float64 + CompletedCycles int + AvgPerCycle float64 + PeakCycle float64 + TotalTracked float64 + TrackingSince time.Time +} + +func NewOpenCodeTracker(store *store.Store, logger *slog.Logger) *OpenCodeTracker { + if logger == nil { + logger = slog.Default() + } + return &OpenCodeTracker{ + store: store, + logger: logger, + lastValues: make(map[string]float64), + lastResets: make(map[string]string), + } +} + +func (t *OpenCodeTracker) Process(snapshot *api.OpenCodeSnapshot) error { + for _, quota := range snapshot.Quotas { + if err := t.processQuota(quota, snapshot.CapturedAt); err != nil { + return fmt.Errorf("opencode tracker: %s: %w", quota.Name, err) + } + } + + t.hasLast = true + return nil +} + +func (t *OpenCodeTracker) processQuota(quota api.OpenCodeQuota, capturedAt time.Time) error { + quotaName := quota.Name + currentUtil := quota.Utilization + + cycle, err := t.store.QueryActiveOpenCodeCycle(quotaName) + if err != nil { + return fmt.Errorf("failed to query active cycle: %w", err) + } + + if cycle == nil { + _, err := t.store.CreateOpenCodeCycle(quotaName, capturedAt, quota.ResetsAt) + if err != nil { + return fmt.Errorf("failed to create cycle: %w", err) + } + if err := t.store.UpdateOpenCodeCycle(quotaName, currentUtil, 0); err != nil { + return fmt.Errorf("failed to set initial peak: %w", err) + } + t.lastValues[quotaName] = currentUtil + if quota.ResetsAt != nil { + t.lastResets[quotaName] = quota.ResetsAt.Format(time.RFC3339Nano) + } + t.logger.Info("Created new OpenCode cycle", + "quota", quotaName, + "resetsAt", quota.ResetsAt, + "initialUtil", currentUtil, + ) + return nil + } + + resetDetected := false + resetReason := "" + if cycle.ResetsAt != nil && capturedAt.After(cycle.ResetsAt.Add(2*time.Minute)) { + resetDetected = true + resetReason = "time-based (stored ResetsAt passed)" + } + + if !resetDetected { + if quota.ResetsAt != nil && cycle.ResetsAt != nil { + diff := quota.ResetsAt.Sub(*cycle.ResetsAt) + if diff < 0 { + diff = -diff + } + if diff > 10*time.Minute { + resetDetected = true + resetReason = "api-based (ResetsAt changed)" + } + } else if quota.ResetsAt != nil && cycle.ResetsAt == nil { + resetDetected = true + resetReason = "api-based (new ResetsAt appeared)" + } + } + + if resetDetected { + cycleEndTime := capturedAt + if cycle.ResetsAt != nil && capturedAt.After(*cycle.ResetsAt) { + cycleEndTime = *cycle.ResetsAt + } + + if t.hasLast { + if lastUtil, ok := t.lastValues[quotaName]; ok { + delta := currentUtil - lastUtil + if delta > 0 { + cycle.TotalDelta += delta + } + if currentUtil > cycle.PeakUtilization { + cycle.PeakUtilization = currentUtil + } + } + } + + if err := t.store.CloseOpenCodeCycle(quotaName, cycleEndTime, cycle.PeakUtilization, cycle.TotalDelta); err != nil { + return fmt.Errorf("failed to close cycle: %w", err) + } + + if _, err := t.store.CreateOpenCodeCycle(quotaName, capturedAt, quota.ResetsAt); err != nil { + return fmt.Errorf("failed to create new cycle: %w", err) + } + if err := t.store.UpdateOpenCodeCycle(quotaName, currentUtil, 0); err != nil { + return fmt.Errorf("failed to set initial peak: %w", err) + } + + t.lastValues[quotaName] = currentUtil + if quota.ResetsAt != nil { + t.lastResets[quotaName] = quota.ResetsAt.Format(time.RFC3339Nano) + } + t.logger.Info("Detected OpenCode quota reset", + "quota", quotaName, + "reason", resetReason, + "oldResetsAt", cycle.ResetsAt, + "newResetsAt", quota.ResetsAt, + "cycleEndTime", cycleEndTime, + "totalDelta", cycle.TotalDelta, + ) + if t.onReset != nil { + t.onReset(quotaName) + } + return nil + } + + if t.hasLast { + if lastUtil, ok := t.lastValues[quotaName]; ok { + delta := currentUtil - lastUtil + if delta > 0 { + cycle.TotalDelta += delta + } + if currentUtil > cycle.PeakUtilization { + cycle.PeakUtilization = currentUtil + } + if err := t.store.UpdateOpenCodeCycle(quotaName, cycle.PeakUtilization, cycle.TotalDelta); err != nil { + return fmt.Errorf("failed to update cycle: %w", err) + } + } else { + if currentUtil > cycle.PeakUtilization { + cycle.PeakUtilization = currentUtil + if err := t.store.UpdateOpenCodeCycle(quotaName, cycle.PeakUtilization, cycle.TotalDelta); err != nil { + return fmt.Errorf("failed to update cycle: %w", err) + } + } + } + } else { + if currentUtil > cycle.PeakUtilization { + cycle.PeakUtilization = currentUtil + if err := t.store.UpdateOpenCodeCycle(quotaName, cycle.PeakUtilization, cycle.TotalDelta); err != nil { + return fmt.Errorf("failed to update cycle: %w", err) + } + } + } + + t.lastValues[quotaName] = currentUtil + if quota.ResetsAt != nil { + t.lastResets[quotaName] = quota.ResetsAt.Format(time.RFC3339Nano) + } + return nil +} + +func (t *OpenCodeTracker) UsageSummary(quotaName string) (*OpenCodeSummary, error) { + activeCycle, err := t.store.QueryActiveOpenCodeCycle(quotaName) + if err != nil { + return nil, fmt.Errorf("failed to query active cycle: %w", err) + } + + history, err := t.store.QueryOpenCodeCycleHistory(quotaName) + if err != nil { + return nil, fmt.Errorf("failed to query cycle history: %w", err) + } + + summary := &OpenCodeSummary{ + QuotaName: quotaName, + CompletedCycles: len(history), + } + + if len(history) > 0 { + var totalDelta float64 + summary.TrackingSince = history[len(history)-1].CycleStart + + for _, cycle := range history { + totalDelta += cycle.TotalDelta + if cycle.PeakUtilization > summary.PeakCycle { + summary.PeakCycle = cycle.PeakUtilization + } + } + summary.AvgPerCycle = totalDelta / float64(len(history)) + summary.TotalTracked = totalDelta + } + + if activeCycle != nil { + summary.TotalTracked += activeCycle.TotalDelta + if activeCycle.PeakUtilization > summary.PeakCycle { + summary.PeakCycle = activeCycle.PeakUtilization + } + if activeCycle.ResetsAt != nil { + summary.ResetsAt = activeCycle.ResetsAt + summary.TimeUntilReset = time.Until(*activeCycle.ResetsAt) + } + + latest, err := t.store.QueryLatestOpenCode() + if err != nil { + return nil, fmt.Errorf("failed to query latest: %w", err) + } + + if latest != nil { + for _, q := range latest.Quotas { + if q.Name == quotaName { + summary.CurrentUtil = q.Utilization + if summary.ResetsAt == nil && q.ResetsAt != nil { + summary.ResetsAt = q.ResetsAt + summary.TimeUntilReset = time.Until(*q.ResetsAt) + } + break + } + } + + elapsed := time.Since(activeCycle.CycleStart) + if elapsed.Minutes() >= 30 && activeCycle.TotalDelta > 0 { + summary.CurrentRate = activeCycle.TotalDelta / elapsed.Hours() + if summary.ResetsAt != nil { + hoursLeft := time.Until(*summary.ResetsAt).Hours() + if hoursLeft > 0 { + projected := summary.CurrentUtil + (summary.CurrentRate * hoursLeft) + if projected > 100 { + projected = 100 + } + summary.ProjectedUtil = projected + } + } + } + } + } + + return summary, nil +} diff --git a/internal/tracker/opencode_tracker_test.go b/internal/tracker/opencode_tracker_test.go new file mode 100644 index 00000000..da1afbfb --- /dev/null +++ b/internal/tracker/opencode_tracker_test.go @@ -0,0 +1,172 @@ +package tracker + +import ( + "log/slog" + "testing" + "time" + + "github.com/onllm-dev/onwatch/v2/internal/api" + "github.com/onllm-dev/onwatch/v2/internal/store" +) + +func newTestOpenCodeStore(t *testing.T) *store.Store { + t.Helper() + s, err := store.New(":memory:") + if err != nil { + t.Fatalf("store.New: %v", err) + } + t.Cleanup(func() { s.Close() }) + return s +} + +func TestOpenCodeTracker_Process_FirstSnapshot(t *testing.T) { + s := newTestOpenCodeStore(t) + tr := NewOpenCodeTracker(s, slog.Default()) + + now := time.Now().UTC() + resetsAt := now.Add(5 * time.Hour) + + snapshot := &api.OpenCodeSnapshot{ + CapturedAt: now, + AccountType: api.OpenCodeAccountTypePro, + PlanName: "OpenCode Go", + Quotas: []api.OpenCodeQuota{ + {Name: "five_hour", Utilization: 12.5, Format: api.OpenCodeQuotaFormatPercent, ResetsAt: &resetsAt}, + }, + } + + if err := tr.Process(snapshot); err != nil { + t.Fatalf("Process: %v", err) + } + + cycle, err := s.QueryActiveOpenCodeCycle("five_hour") + if err != nil { + t.Fatalf("QueryActiveOpenCodeCycle: %v", err) + } + if cycle == nil { + t.Fatal("expected active cycle after first snapshot") + } + if cycle.PeakUtilization != 12.5 { + t.Errorf("PeakUtilization = %f, want 12.5", cycle.PeakUtilization) + } +} + +func TestOpenCodeTracker_Process_UsageIncrease(t *testing.T) { + s := newTestOpenCodeStore(t) + tr := NewOpenCodeTracker(s, slog.Default()) + + now := time.Now().UTC() + resetsAt := now.Add(5 * time.Hour) + + snap1 := &api.OpenCodeSnapshot{ + CapturedAt: now, + Quotas: []api.OpenCodeQuota{ + {Name: "five_hour", Utilization: 12.5, Format: api.OpenCodeQuotaFormatPercent, ResetsAt: &resetsAt}, + }, + } + if err := tr.Process(snap1); err != nil { + t.Fatalf("Process snap1: %v", err) + } + + snap2 := &api.OpenCodeSnapshot{ + CapturedAt: now.Add(time.Minute), + Quotas: []api.OpenCodeQuota{ + {Name: "five_hour", Utilization: 25.0, Format: api.OpenCodeQuotaFormatPercent, ResetsAt: &resetsAt}, + }, + } + if err := tr.Process(snap2); err != nil { + t.Fatalf("Process snap2: %v", err) + } + + cycle, err := s.QueryActiveOpenCodeCycle("five_hour") + if err != nil { + t.Fatalf("QueryActiveOpenCodeCycle: %v", err) + } + if cycle == nil { + t.Fatal("expected active cycle") + } + if cycle.PeakUtilization != 25.0 { + t.Errorf("PeakUtilization = %f, want 25.0", cycle.PeakUtilization) + } + if cycle.TotalDelta != 12.5 { + t.Errorf("TotalDelta = %f, want 12.5", cycle.TotalDelta) + } +} + +func TestOpenCodeTracker_Process_ResetDetection(t *testing.T) { + s := newTestOpenCodeStore(t) + tr := NewOpenCodeTracker(s, slog.Default()) + + now := time.Now().UTC() + oldReset := now.Add(1 * time.Hour) + newReset := now.Add(6 * time.Hour) + + snap1 := &api.OpenCodeSnapshot{ + CapturedAt: now, + Quotas: []api.OpenCodeQuota{ + {Name: "weekly", Utilization: 40, Format: api.OpenCodeQuotaFormatPercent, ResetsAt: &oldReset}, + }, + } + if err := tr.Process(snap1); err != nil { + t.Fatalf("Process snap1: %v", err) + } + + snap2 := &api.OpenCodeSnapshot{ + CapturedAt: now.Add(2 * time.Hour), + Quotas: []api.OpenCodeQuota{ + {Name: "weekly", Utilization: 5, Format: api.OpenCodeQuotaFormatPercent, ResetsAt: &newReset}, + }, + } + if err := tr.Process(snap2); err != nil { + t.Fatalf("Process snap2: %v", err) + } + + history, err := s.QueryOpenCodeCycleHistory("weekly") + if err != nil { + t.Fatalf("QueryOpenCodeCycleHistory: %v", err) + } + if len(history) != 1 { + t.Fatalf("completed cycles = %d, want 1", len(history)) + } + + active, err := s.QueryActiveOpenCodeCycle("weekly") + if err != nil { + t.Fatalf("QueryActiveOpenCodeCycle: %v", err) + } + if active == nil { + t.Fatal("expected new active cycle after reset") + } +} + +func TestOpenCodeTracker_UsageSummary(t *testing.T) { + s := newTestOpenCodeStore(t) + tr := NewOpenCodeTracker(s, slog.Default()) + + now := time.Now().UTC() + resetsAt := now.Add(5 * time.Hour) + snap := &api.OpenCodeSnapshot{ + CapturedAt: now, + AccountType: api.OpenCodeAccountTypePro, + PlanName: "OpenCode Go", + Quotas: []api.OpenCodeQuota{ + {Name: "five_hour", Utilization: 20, Format: api.OpenCodeQuotaFormatPercent, ResetsAt: &resetsAt}, + }, + } + if _, err := s.InsertOpenCodeSnapshot(snap); err != nil { + t.Fatalf("InsertOpenCodeSnapshot: %v", err) + } + if err := tr.Process(snap); err != nil { + t.Fatalf("Process: %v", err) + } + + summary, err := tr.UsageSummary("five_hour") + if err != nil { + t.Fatalf("UsageSummary: %v", err) + } + if summary == nil { + t.Fatal("expected summary") + } + if summary.CurrentUtil != 20 { + t.Errorf("CurrentUtil = %f, want 20", summary.CurrentUtil) + } +} diff --git a/internal/web/handlers.go b/internal/web/handlers.go index 726354dc..aac3d019 100644 --- a/internal/web/handlers.go +++ b/internal/web/handlers.go @@ -96,6 +96,7 @@ type Handler struct { cursorTracker *tracker.CursorTracker grokTracker *tracker.GrokTracker kimiTracker *tracker.KimiTracker + opencodeTracker *tracker.OpenCodeTracker updater *update.Updater notifier Notifier agentManager ProviderAgentController @@ -1121,6 +1122,7 @@ func providerCatalog() []providerCatalogItem { {Key: "cursor", Name: "Cursor", Description: "Cursor usage and quota tracking", AutoDetectable: true}, {Key: "grok", Name: "Grok", Description: "Grok (xAI) usage tracking", AutoDetectable: true}, {Key: "kimi", Name: "Kimi Code", Description: "Kimi Code CLI OAuth quota tracking", AutoDetectable: true}, + {Key: "opencode", Name: "OpenCode Go", Description: "OpenCode Go quota tracking", AutoDetectable: false}, } } @@ -1197,6 +1199,8 @@ func (h *Handler) isProviderConfigured(provider string) bool { return true } return api.DetectKimiCredentials(h.logger) != nil + case "opencode": + return h.config != nil && strings.TrimSpace(h.config.OpenCodeGoWorkspaceID) != "" && strings.TrimSpace(h.config.OpenCodeGoAuthCookie) != "" default: return false } @@ -1345,6 +1349,8 @@ func applyProviderConfig(dst, src *config.Config) { dst.CodexAutoToken = src.CodexAutoToken dst.CodexAutoSource = src.CodexAutoSource dst.OpenCodeEnabled = src.OpenCodeEnabled + dst.OpenCodeGoWorkspaceID = src.OpenCodeGoWorkspaceID + dst.OpenCodeGoAuthCookie = src.OpenCodeGoAuthCookie dst.AntigravityBaseURL = src.AntigravityBaseURL dst.AntigravityCSRFToken = src.AntigravityCSRFToken dst.AntigravityEnabled = src.AntigravityEnabled @@ -1434,6 +1440,9 @@ var providerEnumFields = map[string]map[string][]string{ "cursor": { "display_mode": {"usage", "available"}, }, + "opencode": { + "display_mode": {"usage", "available"}, + }, } // sanitizeProviderSettings validates enum fields and resets invalid values @@ -1541,6 +1550,12 @@ func ApplyProviderSettingsFromDB(st *store.Store, cfg *config.Config, logger *sl if enabled, ok := s["enabled"].(bool); ok { cfg.OpenCodeEnabled = enabled } + if id, _ := s["workspace_id"].(string); id != "" { + cfg.OpenCodeGoWorkspaceID = id + } + if cookie, _ := s["auth_cookie"].(string); cookie != "" { + cfg.OpenCodeGoAuthCookie = cookie + } } if logger != nil { @@ -1849,6 +1864,8 @@ func (h *Handler) Current(w http.ResponseWriter, r *http.Request) { h.currentGrok(w, r) case "kimi": h.currentKimi(w, r) + case "opencode": + h.currentOpenCode(w, r) default: respondError(w, http.StatusBadRequest, fmt.Sprintf("unknown provider: %s", provider)) } @@ -2302,6 +2319,9 @@ func (h *Handler) currentBoth(w http.ResponseWriter, r *http.Request) { if h.config.HasProvider("kimi") && providerTelemetryEnabled(visibility, "kimi") { response["kimi"] = h.buildKimiCurrent() } + if h.config.HasProvider("opencode") && providerTelemetryEnabled(visibility, "opencode") { + response["opencode"] = h.buildOpenCodeCurrent() + } respondJSON(w, http.StatusOK, response) } @@ -2652,6 +2672,8 @@ func (h *Handler) History(w http.ResponseWriter, r *http.Request) { h.historyGrok(w, r) case "kimi": h.historyKimi(w, r) + case "opencode": + h.historyOpenCode(w, r) default: respondError(w, http.StatusBadRequest, fmt.Sprintf("unknown provider: %s", provider)) } @@ -3673,6 +3695,8 @@ func (h *Handler) Cycles(w http.ResponseWriter, r *http.Request) { respondJSON(w, http.StatusOK, map[string]interface{}{"cycles": []interface{}{}}) case "kimi": respondJSON(w, http.StatusOK, map[string]interface{}{"cycles": []interface{}{}}) + case "opencode": + h.cyclesOpenCode(w, r) default: respondError(w, http.StatusBadRequest, fmt.Sprintf("unknown provider: %s", provider)) } @@ -4044,6 +4068,8 @@ func (h *Handler) Summary(w http.ResponseWriter, r *http.Request) { respondJSON(w, http.StatusOK, map[string]interface{}{"summaries": []interface{}{}}) case "kimi": respondJSON(w, http.StatusOK, map[string]interface{}{"summaries": []interface{}{}}) + case "opencode": + h.summaryOpenCode(w, r) default: respondError(w, http.StatusBadRequest, fmt.Sprintf("unknown provider: %s", provider)) } @@ -4861,6 +4887,8 @@ func (h *Handler) Insights(w http.ResponseWriter, r *http.Request) { h.insightsGrok(w, r, rangeDur) case "kimi": h.insightsKimi(w, r, rangeDur) + case "opencode": + h.insightsOpenCode(w, r, rangeDur) default: respondError(w, http.StatusBadRequest, fmt.Sprintf("unknown provider: %s", provider)) } @@ -4955,6 +4983,9 @@ func (h *Handler) insightsBoth(w http.ResponseWriter, r *http.Request, rangeDur if h.config.HasProvider("kimi") && providerTelemetryEnabled(visibility, "kimi") { response["kimi"] = h.buildKimiInsights(hidden) } + if h.config.HasProvider("opencode") && providerTelemetryEnabled(visibility, "opencode") { + response["opencode"] = h.buildOpenCodeInsights(hidden, rangeDur) + } respondJSON(w, http.StatusOK, response) } @@ -7363,6 +7394,8 @@ func (h *Handler) CycleOverview(w http.ResponseWriter, r *http.Request) { h.cycleOverviewGrok(w, r) case "kimi": h.cycleOverviewKimi(w, r) + case "opencode": + h.cycleOverviewOpenCode(w, r) default: respondError(w, http.StatusBadRequest, fmt.Sprintf("unknown provider: %s", provider)) } @@ -11004,6 +11037,8 @@ func (h *Handler) LoggingHistory(w http.ResponseWriter, r *http.Request) { h.loggingHistoryGrok(w, r) case "kimi": h.loggingHistoryKimi(w, r) + case "opencode": + h.loggingHistoryOpenCode(w, r) default: respondError(w, http.StatusBadRequest, fmt.Sprintf("unknown provider: %s", provider)) } diff --git a/internal/web/opencode_handlers.go b/internal/web/opencode_handlers.go new file mode 100644 index 00000000..47412bec --- /dev/null +++ b/internal/web/opencode_handlers.go @@ -0,0 +1,628 @@ +package web + +import ( + "fmt" + "net/http" + "sort" + "time" + + "github.com/onllm-dev/onwatch/v2/internal/api" + "github.com/onllm-dev/onwatch/v2/internal/tracker" +) + +// SetOpenCodeTracker sets the OpenCode tracker for usage summary enrichment. +func (h *Handler) SetOpenCodeTracker(t *tracker.OpenCodeTracker) { + h.opencodeTracker = t +} + +func (h *Handler) currentOpenCode(w http.ResponseWriter, _ *http.Request) { + respondJSON(w, http.StatusOK, h.buildOpenCodeCurrent()) +} + +// opencodeInsightsResponse is the JSON payload for OpenCode deep insights. +type opencodeInsightsResponse struct { + Stats []opencodeInsightStat `json:"stats"` + Insights []insightItem `json:"insights"` +} + +// opencodeInsightStat is a stats-row shape that carries linked forecast metadata for the OpenCode dashboard. +type opencodeInsightStat struct { + Value string `json:"value"` + Label string `json:"label"` + Sublabel string `json:"sublabel,omitempty"` + Key string `json:"key,omitempty"` + Metric string `json:"metric,omitempty"` + Severity string `json:"severity,omitempty"` + Desc string `json:"desc,omitempty"` +} + +var opencodeQuotaDisplayOrder = map[string]int{ + "five_hour": 1, + "weekly": 2, + "monthly": 3, +} + +var opencodeDisplayNames = map[string]string{ + "five_hour": "5-Hour", + "weekly": "Weekly", + "monthly": "Monthly", +} + +func opencodeDisplayName(name string) string { + if dn, ok := opencodeDisplayNames[name]; ok { + return dn + } + return name +} + +func opencodeQuotaOrder(name string) int { + if order, ok := opencodeQuotaDisplayOrder[name]; ok { + return order + } + return 99 +} + +type opencodeQuotaRate struct { + Rate float64 + HasRate bool + TimeToReset time.Duration + TimeToExhaust time.Duration + ExhaustsFirst bool + ProjectedPct float64 +} + +func (h *Handler) computeOpenCodeRate(quotaName string, currentUtil float64, summary *tracker.OpenCodeSummary) opencodeQuotaRate { + var result opencodeQuotaRate + + if summary != nil && summary.ResetsAt != nil { + result.TimeToReset = time.Until(*summary.ResetsAt) + } + + if h.store != nil { + points, err := h.store.QueryOpenCodeUtilizationSeries(quotaName, time.Now().Add(-30*time.Minute)) + if err == nil && len(points) >= 2 { + first := points[0] + last := points[len(points)-1] + elapsed := last.CapturedAt.Sub(first.CapturedAt) + if elapsed >= 5*time.Minute { + delta := last.Utilization - first.Utilization + if delta > 0 { + result.Rate = delta / elapsed.Hours() + result.HasRate = true + } else { + result.HasRate = true + } + } + } + } + + if !result.HasRate && summary != nil && summary.CurrentRate > 0 { + result.Rate = summary.CurrentRate + result.HasRate = true + } + + if result.HasRate && result.Rate > 0 { + remaining := 100 - currentUtil + if remaining > 0 { + result.TimeToExhaust = time.Duration(remaining / result.Rate * float64(time.Hour)) + } + if result.TimeToReset > 0 { + result.ProjectedPct = currentUtil + (result.Rate * result.TimeToReset.Hours()) + if result.ProjectedPct > 100 { + result.ProjectedPct = 100 + } + result.ExhaustsFirst = result.TimeToExhaust > 0 && result.TimeToExhaust < result.TimeToReset + } + } + + return result +} + +func buildOpenCodeBurnRateInsight(quota api.OpenCodeQuota, rate opencodeQuotaRate) insightItem { + item := insightItem{ + Key: fmt.Sprintf("forecast_%s", quota.Name), + Title: fmt.Sprintf("%s Burn Rate", opencodeDisplayName(quota.Name)), + } + + resetStr := "" + if rate.TimeToReset > 0 { + resetStr = formatDuration(rate.TimeToReset) + } + projected := quota.Utilization + if rate.ProjectedPct > projected { + projected = rate.ProjectedPct + } + sublabel := fmt.Sprintf("~%.0f%% by reset", projected) + if resetStr != "" { + sublabel = fmt.Sprintf("~%.0f%% by reset in %s", projected, resetStr) + } + + if !rate.HasRate { + item.Type = "forecast" + item.Severity = "info" + item.Metric = "Analyzing..." + item.Sublabel = sublabel + item.Desc = fmt.Sprintf("Currently at %.0f%%. Collecting more snapshots to estimate burn rate and refine reset projection.", quota.Utilization) + return item + } + + if rate.Rate < 0.01 { + item.Type = "forecast" + item.Severity = "positive" + item.Metric = "Idle" + item.Sublabel = sublabel + item.Desc = fmt.Sprintf("Currently at %.0f%%. No meaningful burn detected recently, so this quota looks stable through the rest of the cycle.", quota.Utilization) + return item + } + + item.Type = "forecast" + item.Metric = fmt.Sprintf("%.1f%%/hr", rate.Rate) + if rate.ExhaustsFirst { + exhaustStr := formatDuration(rate.TimeToExhaust) + item.Severity = "negative" + item.Sublabel = sublabel + item.Desc = fmt.Sprintf("Currently at %.0f%%. At this rate, projected %.0f%% by reset and likely to exhaust in %s before reset.", quota.Utilization, projected, exhaustStr) + return item + } + + if rate.ProjectedPct >= 80 { + item.Severity = "warning" + item.Sublabel = sublabel + item.Desc = fmt.Sprintf("Currently at %.0f%%. At this rate, projected %.0f%% by reset.", quota.Utilization, projected) + return item + } + + item.Severity = "positive" + item.Sublabel = sublabel + item.Desc = fmt.Sprintf("Currently at %.0f%%. At this rate, projected %.0f%% by reset.", quota.Utilization, projected) + return item +} + +func (h *Handler) buildOpenCodeCurrent() map[string]interface{} { + now := time.Now().UTC() + response := map[string]interface{}{ + "capturedAt": now.Format(time.RFC3339), + "quotas": []interface{}{}, + } + + if h.store == nil { + return response + } + + latest, err := h.store.QueryLatestOpenCode() + if err != nil || latest == nil { + return response + } + + response["capturedAt"] = latest.CapturedAt.Format(time.RFC3339) + response["accountType"] = string(latest.AccountType) + response["planName"] = latest.PlanName + + latestPerQuota, err := h.store.QueryOpenCodeLatestPerQuota() + if err != nil || len(latestPerQuota) == 0 { + for _, q := range latest.Quotas { + quotaMap := map[string]interface{}{ + "name": q.Name, + "displayName": opencodeDisplayName(q.Name), + "utilization": q.Utilization, + "used": q.Used, + "limit": q.Limit, + "format": string(q.Format), + "status": utilStatus(q.Utilization), + "lastUpdatedAt": latest.CapturedAt.Format(time.RFC3339), + "ageSeconds": int64(now.Sub(latest.CapturedAt).Seconds()), + } + if q.ResetsAt != nil { + timeUntilReset := time.Until(*q.ResetsAt) + quotaMap["resetsAt"] = q.ResetsAt.Format(time.RFC3339) + quotaMap["timeUntilReset"] = formatDuration(timeUntilReset) + quotaMap["timeUntilResetSeconds"] = int64(timeUntilReset.Seconds()) + } + if h.opencodeTracker != nil { + if summary, sErr := h.opencodeTracker.UsageSummary(q.Name); sErr == nil && summary != nil { + quotaMap["currentRate"] = summary.CurrentRate + quotaMap["projectedUtil"] = summary.ProjectedUtil + } + } + response["quotas"] = append(response["quotas"].([]interface{}), quotaMap) + } + applyDisplayModeToResponse(response, h.getDisplayMode("opencode")) + return response + } + + sort.SliceStable(latestPerQuota, func(i, j int) bool { + left := opencodeQuotaOrder(latestPerQuota[i].Name) + right := opencodeQuotaOrder(latestPerQuota[j].Name) + if left != right { + return left < right + } + return latestPerQuota[i].Name < latestPerQuota[j].Name + }) + + var quotas []interface{} + for _, q := range latestPerQuota { + age := now.Sub(q.CapturedAt) + qMap := map[string]interface{}{ + "name": q.Name, + "displayName": opencodeDisplayName(q.Name), + "utilization": q.Utilization, + "used": q.Used, + "limit": q.Limit, + "format": q.Format, + "status": utilStatus(q.Utilization), + "lastUpdatedAt": q.CapturedAt.Format(time.RFC3339), + "ageSeconds": int64(age.Seconds()), + "isStale": age > 30*time.Minute, + } + if q.ResetsAt != nil { + timeUntilReset := time.Until(*q.ResetsAt) + qMap["resetsAt"] = q.ResetsAt.Format(time.RFC3339) + qMap["timeUntilReset"] = formatDuration(timeUntilReset) + qMap["timeUntilResetSeconds"] = int64(timeUntilReset.Seconds()) + } + if h.opencodeTracker != nil { + if summary, sErr := h.opencodeTracker.UsageSummary(q.Name); sErr == nil && summary != nil { + qMap["currentRate"] = summary.CurrentRate + qMap["projectedUtil"] = summary.ProjectedUtil + } + } + quotas = append(quotas, qMap) + } + response["quotas"] = quotas + applyDisplayModeToResponse(response, h.getDisplayMode("opencode")) + return response +} + +func (h *Handler) historyOpenCode(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + if h.store == nil { + respondJSON(w, http.StatusOK, []interface{}{}) + return + } + + rangeParam := r.URL.Query().Get("range") + if rangeParam == "" { + rangeParam = "7d" + } + + now := time.Now().UTC() + var start time.Time + switch rangeParam { + case "1h": + start = now.Add(-1 * time.Hour) + case "6h": + start = now.Add(-6 * time.Hour) + case "24h", "1d": + start = now.Add(-24 * time.Hour) + case "3d": + start = now.Add(-3 * 24 * time.Hour) + case "30d": + start = now.Add(-30 * 24 * time.Hour) + case "7d": + start = now.Add(-7 * 24 * time.Hour) + default: + start = now.Add(-7 * 24 * time.Hour) + } + + snapshots, err := h.store.QueryOpenCodeRange(start, now, 200) + if err != nil { + h.logger.Error("failed to query OpenCode history", "error", err) + respondError(w, http.StatusInternalServerError, "failed to query history") + return + } + + type historyEntry struct { + CapturedAt string `json:"capturedAt"` + Quotas []map[string]interface{} `json:"quotas"` + } + + result := make([]historyEntry, 0, len(snapshots)) + for _, snap := range snapshots { + entry := historyEntry{ + CapturedAt: snap.CapturedAt.Format(time.RFC3339), + } + for _, q := range snap.Quotas { + qMap := map[string]interface{}{ + "name": q.Name, + "utilization": q.Utilization, + "used": q.Used, + "limit": q.Limit, + "format": string(q.Format), + } + if q.ResetsAt != nil { + qMap["resetsAt"] = q.ResetsAt.Format(time.RFC3339) + } + entry.Quotas = append(entry.Quotas, qMap) + } + result = append(result, entry) + } + + respondJSON(w, http.StatusOK, result) +} + +func (h *Handler) cyclesOpenCode(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + if h.store == nil { + respondJSON(w, http.StatusOK, []interface{}{}) + return + } + + quotaName := r.URL.Query().Get("type") + if quotaName == "" { + quotaName = "five_hour" + } + + active, err := h.store.QueryActiveOpenCodeCycle(quotaName) + if err != nil { + h.logger.Error("failed to query active OpenCode cycle", "error", err) + respondError(w, http.StatusInternalServerError, "failed to query cycles") + return + } + + history, err := h.store.QueryOpenCodeCycleHistory(quotaName, 50) + if err != nil { + h.logger.Error("failed to query OpenCode cycle history", "error", err) + respondError(w, http.StatusInternalServerError, "failed to query cycles") + return + } + + var cycles []map[string]interface{} + if active != nil { + cycleMap := map[string]interface{}{ + "id": active.ID, + "quotaName": active.QuotaName, + "cycleStart": active.CycleStart.Format(time.RFC3339), + "cycleEnd": nil, + "peakUtilization": active.PeakUtilization, + "totalDelta": active.TotalDelta, + "isActive": true, + } + if active.ResetsAt != nil { + cycleMap["resetsAt"] = active.ResetsAt.Format(time.RFC3339) + cycleMap["timeUntilReset"] = formatDuration(time.Until(*active.ResetsAt)) + } + cycles = append(cycles, cycleMap) + } + + for _, c := range history { + cycleMap := map[string]interface{}{ + "id": c.ID, + "quotaName": c.QuotaName, + "cycleStart": c.CycleStart.Format(time.RFC3339), + "cycleEnd": c.CycleEnd.Format(time.RFC3339), + "peakUtilization": c.PeakUtilization, + "totalDelta": c.TotalDelta, + "isActive": false, + } + if c.ResetsAt != nil { + cycleMap["resetsAt"] = c.ResetsAt.Format(time.RFC3339) + } + cycles = append(cycles, cycleMap) + } + + respondJSON(w, http.StatusOK, cycles) +} + +func (h *Handler) cycleOverviewOpenCode(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + if h.store == nil { + respondJSON(w, http.StatusOK, []interface{}{}) + return + } + + groupBy := r.URL.Query().Get("group_by") + if groupBy == "" { + groupBy = "five_hour" + } + + overview, err := h.store.QueryOpenCodeCycleOverview(groupBy, 50) + if err != nil { + h.logger.Error("failed to query OpenCode cycle overview", "error", err) + respondError(w, http.StatusInternalServerError, "failed to query cycle overview") + return + } + + respondJSON(w, http.StatusOK, overview) +} + +func (h *Handler) summaryOpenCode(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + respondJSON(w, http.StatusOK, h.buildOpenCodeSummaryMap()) +} + +func (h *Handler) insightsOpenCode(w http.ResponseWriter, _ *http.Request, rangeDur time.Duration) { + hidden := h.getHiddenInsightKeys() + respondJSON(w, http.StatusOK, h.buildOpenCodeInsights(hidden, rangeDur)) +} + +func (h *Handler) opencodeQuotaNames() []string { + if h.store == nil { + return nil + } + names, err := h.store.QueryAllOpenCodeQuotaNames() + if err != nil { + return nil + } + return names +} + +func (h *Handler) buildOpenCodeSummaryMap() map[string]interface{} { + if h.store == nil || h.opencodeTracker == nil { + return map[string]interface{}{} + } + + quotaNames, err := h.store.QueryAllOpenCodeQuotaNames() + if err != nil { + h.logger.Error("failed to query OpenCode quota names", "error", err) + return map[string]interface{}{} + } + + result := make(map[string]interface{}) + for _, name := range quotaNames { + summary, err := h.opencodeTracker.UsageSummary(name) + if err != nil || summary == nil { + continue + } + entry := map[string]interface{}{ + "currentUtil": summary.CurrentUtil, + "completedCycles": summary.CompletedCycles, + "peakCycle": summary.PeakCycle, + "avgPerCycle": summary.AvgPerCycle, + "totalTracked": summary.TotalTracked, + } + if summary.ResetsAt != nil { + entry["resetsAt"] = summary.ResetsAt.Format(time.RFC3339) + entry["timeUntilReset"] = formatDuration(summary.TimeUntilReset) + } + result[name] = entry + } + return result +} + +func (h *Handler) buildOpenCodeInsights(hidden map[string]bool, _ time.Duration) opencodeInsightsResponse { + resp := opencodeInsightsResponse{Stats: []opencodeInsightStat{}, Insights: []insightItem{}} + + if h.store == nil { + return resp + } + + latest, err := h.store.QueryLatestOpenCode() + if err != nil || latest == nil || len(latest.Quotas) == 0 { + return resp + } + + planLabel := latest.PlanName + if planLabel == "" { + planLabel = string(latest.AccountType) + } + if planLabel != "" { + resp.Stats = append(resp.Stats, opencodeInsightStat{ + Label: "Plan", + Value: planLabel, + }) + } + + quotas := append([]api.OpenCodeQuota(nil), latest.Quotas...) + sort.SliceStable(quotas, func(i, j int) bool { + left := opencodeQuotaOrder(quotas[i].Name) + right := opencodeQuotaOrder(quotas[j].Name) + if left != right { + return left < right + } + return quotas[i].Name < quotas[j].Name + }) + + summaries := map[string]*tracker.OpenCodeSummary{} + if h.opencodeTracker != nil { + for _, quota := range quotas { + summary, err := h.opencodeTracker.UsageSummary(quota.Name) + if err == nil && summary != nil { + summaries[quota.Name] = summary + } + } + } + + preferredQuotas := []string{"five_hour", "weekly", "monthly"} + selected := make([]api.OpenCodeQuota, 0, len(preferredQuotas)) + for _, name := range preferredQuotas { + for _, quota := range quotas { + if quota.Name == name { + selected = append(selected, quota) + break + } + } + } + if len(selected) == 0 { + selected = quotas + } + + for _, quota := range selected { + rate := h.computeOpenCodeRate(quota.Name, quota.Utilization, summaries[quota.Name]) + insightKey := fmt.Sprintf("forecast_%s", quota.Name) + if hidden[insightKey] { + continue + } + value := "Analyzing..." + if rate.HasRate { + value = fmt.Sprintf("%.1f%%/hr", rate.Rate) + } + insight := buildOpenCodeBurnRateInsight(quota, rate) + resp.Stats = append(resp.Stats, opencodeInsightStat{ + Key: insightKey, + Label: fmt.Sprintf("%s Burn Rate", opencodeDisplayName(quota.Name)), + Value: value, + Sublabel: insight.Sublabel, + Metric: insight.Metric, + Severity: insight.Severity, + Desc: insight.Desc, + }) + } + + return resp +} + +func (h *Handler) loggingHistoryOpenCode(w http.ResponseWriter, r *http.Request) { + if h.store == nil { + respondJSON(w, http.StatusOK, map[string]interface{}{"logs": []interface{}{}}) + return + } + + start, end, limit := h.loggingHistoryRangeAndLimit(r) + snapshots, err := h.store.QueryOpenCodeRange(start, end, limit) + if err != nil { + h.logger.Error("failed to query OpenCode snapshots", "error", err) + respondError(w, http.StatusInternalServerError, "failed to query logging history") + return + } + + quotaSet := map[string]bool{} + for _, snap := range snapshots { + for _, q := range snap.Quotas { + quotaSet[q.Name] = true + } + } + + quotaNames := make([]string, 0, len(quotaSet)) + for qn := range quotaSet { + quotaNames = append(quotaNames, qn) + } + if len(quotaNames) == 0 { + quotaNames = []string{"five_hour", "weekly", "monthly"} + } else { + sort.SliceStable(quotaNames, func(i, j int) bool { + left := opencodeQuotaOrder(quotaNames[i]) + right := opencodeQuotaOrder(quotaNames[j]) + if left != right { + return left < right + } + return quotaNames[i] < quotaNames[j] + }) + } + + capturedAt := make([]time.Time, 0, len(snapshots)) + ids := make([]int64, 0, len(snapshots)) + series := make([]map[string]loggingHistoryCrossQuota, 0, len(snapshots)) + + for _, snap := range snapshots { + capturedAt = append(capturedAt, snap.CapturedAt) + ids = append(ids, snap.ID) + row := make(map[string]loggingHistoryCrossQuota, len(snap.Quotas)) + for _, q := range snap.Quotas { + row[q.Name] = loggingHistoryCrossQuota{ + Name: q.Name, + Value: q.Used, + Limit: q.Limit, + Percent: q.Utilization, + HasValue: q.Used > 0 || q.Limit > 0, + HasLimit: q.Limit > 0, + } + } + series = append(series, row) + } + + respondJSON(w, http.StatusOK, map[string]interface{}{ + "provider": "opencode", + "quotaNames": quotaNames, + "logs": loggingHistoryRowsFromSnapshots(capturedAt, ids, quotaNames, series), + }) +} diff --git a/internal/web/opencode_handlers_test.go b/internal/web/opencode_handlers_test.go new file mode 100644 index 00000000..b80ca2d0 --- /dev/null +++ b/internal/web/opencode_handlers_test.go @@ -0,0 +1,209 @@ +package web + +import ( + "encoding/json" + "log/slog" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/onllm-dev/onwatch/v2/internal/api" + "github.com/onllm-dev/onwatch/v2/internal/config" + "github.com/onllm-dev/onwatch/v2/internal/store" + "github.com/onllm-dev/onwatch/v2/internal/tracker" +) + +func createTestConfigWithOpenCode() *config.Config { + return &config.Config{ + OpenCodeGoWorkspaceID: "ws-test", + OpenCodeGoAuthCookie: "cookie-test", + PollInterval: 60 * time.Second, + Port: 9211, + AdminUser: "admin", + AdminPass: "test", + DBPath: "./test.db", + } +} + +func insertTestOpenCodeSnapshot(t *testing.T, s *store.Store, capturedAt time.Time, quotas []api.OpenCodeQuota) { + t.Helper() + snap := &api.OpenCodeSnapshot{ + CapturedAt: capturedAt, + AccountType: api.OpenCodeAccountTypePro, + PlanName: "OpenCode Go", + Quotas: quotas, + } + if _, err := s.InsertOpenCodeSnapshot(snap); err != nil { + t.Fatalf("failed to insert test OpenCode snapshot: %v", err) + } +} + +func TestBuildOpenCodeCurrent_UsesLatestSnapshot(t *testing.T) { + s, err := store.New(":memory:") + if err != nil { + t.Fatalf("store.New: %v", err) + } + defer s.Close() + + now := time.Now().UTC() + reset := now.Add(5 * time.Hour) + insertTestOpenCodeSnapshot(t, s, now, []api.OpenCodeQuota{ + {Name: "five_hour", Utilization: 15, Used: 15, Limit: 100, Format: api.OpenCodeQuotaFormatPercent, ResetsAt: &reset}, + }) + + h := NewHandler(s, nil, nil, nil, createTestConfigWithOpenCode()) + current := h.buildOpenCodeCurrent() + + if got := current["planName"]; got != "OpenCode Go" { + t.Fatalf("planName = %v, want OpenCode Go", got) + } + quotas, ok := current["quotas"].([]interface{}) + if !ok || len(quotas) != 1 { + t.Fatalf("quotas = %#v, want one quota", current["quotas"]) + } +} + +func TestCyclesOpenCode_DefaultsToFiveHour(t *testing.T) { + s, err := store.New(":memory:") + if err != nil { + t.Fatalf("store.New: %v", err) + } + defer s.Close() + + now := time.Now().UTC() + reset := now.Add(5 * time.Hour) + if _, err := s.CreateOpenCodeCycle("five_hour", now, &reset); err != nil { + t.Fatalf("CreateOpenCodeCycle: %v", err) + } + + h := NewHandler(s, nil, nil, nil, createTestConfigWithOpenCode()) + req := httptest.NewRequest(http.MethodGet, "/api/opencode/cycles", nil) + rec := httptest.NewRecorder() + h.cyclesOpenCode(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200", rec.Code) + } + + var cycles []map[string]interface{} + if err := json.Unmarshal(rec.Body.Bytes(), &cycles); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if len(cycles) != 1 { + t.Fatalf("cycles = %d, want 1", len(cycles)) + } + if got := cycles[0]["quotaName"]; got != "five_hour" { + t.Fatalf("quotaName = %v, want five_hour", got) + } +} + +func TestCycleOverviewOpenCode_DefaultsToFiveHour(t *testing.T) { + s, err := store.New(":memory:") + if err != nil { + t.Fatalf("store.New: %v", err) + } + defer s.Close() + + now := time.Now().UTC() + reset := now.Add(5 * time.Hour) + if _, err := s.CreateOpenCodeCycle("five_hour", now, &reset); err != nil { + t.Fatalf("CreateOpenCodeCycle: %v", err) + } + + h := NewHandler(s, nil, nil, nil, createTestConfigWithOpenCode()) + req := httptest.NewRequest(http.MethodGet, "/api/opencode/cycle-overview", nil) + rec := httptest.NewRecorder() + h.cycleOverviewOpenCode(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200", rec.Code) + } + + var overview []map[string]interface{} + if err := json.Unmarshal(rec.Body.Bytes(), &overview); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if len(overview) != 1 { + t.Fatalf("overview = %d, want 1", len(overview)) + } + if got := overview[0]["QuotaType"]; got != "five_hour" { + t.Fatalf("QuotaType = %v, want five_hour", got) + } +} + +func TestBuildOpenCodeSummary_WithTracker(t *testing.T) { + s, err := store.New(":memory:") + if err != nil { + t.Fatalf("store.New: %v", err) + } + defer s.Close() + + now := time.Now().UTC() + reset := now.Add(5 * time.Hour) + snap := &api.OpenCodeSnapshot{ + CapturedAt: now, + AccountType: api.OpenCodeAccountTypePro, + PlanName: "OpenCode Go", + Quotas: []api.OpenCodeQuota{ + {Name: "five_hour", Utilization: 30, Format: api.OpenCodeQuotaFormatPercent, ResetsAt: &reset}, + }, + } + if _, err := s.InsertOpenCodeSnapshot(snap); err != nil { + t.Fatalf("insert: %v", err) + } + + tr := tracker.NewOpenCodeTracker(s, slog.Default()) + if err := tr.Process(snap); err != nil { + t.Fatalf("Process: %v", err) + } + + h := NewHandler(s, nil, nil, nil, createTestConfigWithOpenCode()) + h.SetOpenCodeTracker(tr) + + summary := h.buildOpenCodeSummaryMap() + entry, ok := summary["five_hour"].(map[string]interface{}) + if !ok { + t.Fatalf("summary missing five_hour: %#v", summary) + } + if got := entry["currentUtil"]; got != 30.0 { + t.Fatalf("currentUtil = %v, want 30", got) + } +} + +func TestBuildOpenCodeCurrent_QuotaUsesLimitKey(t *testing.T) { + // The dashboard JS reads `limit` (not `limitValue`). Lock that contract so a + // future rename does not silently break the cards (regression: PR #102 frontend + // showed "/ 0" because JS read a non-existent limitValue key). + s, err := store.New(":memory:") + if err != nil { + t.Fatalf("store.New: %v", err) + } + defer s.Close() + + now := time.Now().UTC() + reset := now.Add(5 * time.Hour) + insertTestOpenCodeSnapshot(t, s, now, []api.OpenCodeQuota{ + {Name: "five_hour", Utilization: 15, Used: 15, Limit: 100, Format: api.OpenCodeQuotaFormatPercent, ResetsAt: &reset}, + }) + + h := NewHandler(s, nil, nil, nil, createTestConfigWithOpenCode()) + current := h.buildOpenCodeCurrent() + + quotas, ok := current["quotas"].([]interface{}) + if !ok || len(quotas) == 0 { + t.Fatalf("quotas = %#v, want at least one quota", current["quotas"]) + } + for _, raw := range quotas { + q, ok := raw.(map[string]interface{}) + if !ok { + t.Fatalf("quota entry is not a map: %#v", raw) + } + if _, hasLimit := q["limit"]; !hasLimit { + t.Fatalf("quota map missing 'limit' key: %v", q) + } + if _, hasLimitValue := q["limitValue"]; hasLimitValue { + t.Fatalf("quota map must not use 'limitValue' key: %v", q) + } + } +} diff --git a/internal/web/static/app.js b/internal/web/static/app.js index 93605b92..aa86add5 100644 --- a/internal/web/static/app.js +++ b/internal/web/static/app.js @@ -72,6 +72,8 @@ function getCurrentProvider() { if (grokGrid) return 'grok'; const kimiGrid = document.getElementById('quota-grid-kimi'); if (kimiGrid) return 'kimi'; + const opencodeGrid = document.getElementById('quota-grid-opencode'); + if (opencodeGrid) return 'opencode'; const grid = document.getElementById('quota-grid'); return (grid && grid.dataset.provider) || 'synthetic'; } @@ -940,6 +942,7 @@ function quotaOrderForProvider(provider) { if (provider === 'anthropic') return anthropicQuotaOrder; if (provider === 'codex') return codexQuotaOrder; if (provider === 'cursor') return cursorQuotaOrder; + if (provider === 'opencode') return opencodeQuotaOrder; return []; } @@ -3772,6 +3775,143 @@ function updateKimiQuotaCards(quotas, containerId) { } }); } +const opencodeQuotaOrder = ['five_hour', 'weekly', 'monthly']; +const opencodeDisplayNames = { + five_hour: '5-Hour Quota', + weekly: 'Weekly Quota', + monthly: 'Monthly Quota' +}; +const opencodeChartColorMap = { + five_hour: { border: '#10a37f', bg: 'rgba(16, 163, 127, 0.08)' }, + weekly: { border: '#2563eb', bg: 'rgba(37, 99, 235, 0.08)' }, + monthly: { border: '#9333ea', bg: 'rgba(147, 51, 234, 0.08)' } +}; +const opencodeChartColorFallback = [ + { border: '#f59e0b', bg: 'rgba(245, 158, 11, 0.08)' }, + { border: '#ec4899', bg: 'rgba(236, 72, 153, 0.08)' } +]; + +function renderOpenCodeQuotaCards(quotas, containerId) { + const container = document.getElementById(containerId); + if (!container) return; + + if (!Array.isArray(quotas) || quotas.length === 0) { + container.innerHTML = '

No OpenCode data available

'; + return; + } + + container.innerHTML = quotas.map((q, i) => { + const icon = ''; + const displayName = q.displayName || opencodeDisplayNames[q.name] || q.name; + const displayPct = q.cardPercent != null ? q.cardPercent : (q.utilization || 0); + const usagePct = displayPct.toFixed(1); + const status = q.status || 'healthy'; + const statusCfg = statusConfig[status] || statusConfig.healthy; + const progressId = `progress-opencode-${q.name}`; + const percentId = `percent-opencode-${q.name}`; + const fractionId = `fraction-opencode-${q.name}`; + const statusId = `status-opencode-${q.name}`; + const resetId = `reset-opencode-${q.name}`; + const countdownId = `countdown-opencode-${q.name}`; + + const cardLabel = q.format === 'currency' ? '$' + (q.used || 0).toFixed(2) + ' / $' + (q.limit || 0).toFixed(2) : (q.used || 0) + ' / ' + (q.limit || 0); + + return `
+
+

+ ${icon} + ${displayName} +

+ ${q.timeUntilResetSeconds > 0 ? formatDuration(q.timeUntilResetSeconds) : '--:--'} +
+
+ ${usagePct}% + ${cardLabel} +
+
+
+
+
+
+
+ + + ${statusCfg.label} + + ${q.resetsAt ? formatResetTime(q.resetsAt) : ''} +
+
`; + }).join(''); +} +function updateOpenCodeCard(quota) { + const key = `opencode-${quota.name}`; + const prev = State.currentQuotas[key]; + State.currentQuotas[key] = { + percent: quota.utilization || 0, + used: quota.used || 0, + limit: quota.limit || 0, + status: quota.status || 'healthy', + renewsAt: quota.resetsAt, + timeUntilResetSeconds: quota.timeUntilResetSeconds || 0, + name: quota.name, + displayName: quota.displayName + }; + + const displayPct = quota.cardPercent != null ? quota.cardPercent : (quota.utilization || 0); + const usagePct = displayPct.toFixed(1); + const status = quota.status || 'healthy'; + + const progressEl = document.getElementById(`progress-opencode-${quota.name}`); + const percentEl = document.getElementById(`percent-opencode-${quota.name}`); + const fractionEl = document.getElementById(`fraction-opencode-${quota.name}`); + const statusEl = document.getElementById(`status-opencode-${quota.name}`); + const resetEl = document.getElementById(`reset-opencode-${quota.name}`); + const countdownEl = document.getElementById(`countdown-opencode-${quota.name}`); + + if (progressEl) { + progressEl.style.width = `${usagePct}%`; + progressEl.setAttribute('data-status', status); + const bar = progressEl.parentElement; + if (bar) bar.setAttribute('aria-valuenow', Math.round(displayPct)); + } + if (percentEl) { + const oldVal = prev ? prev.percent : 0; + if (Math.abs(oldVal - displayPct) > 0.2) { + animateValue(percentEl, oldVal, displayPct, 400, v => `${v.toFixed(1)}%`); + } else { + percentEl.textContent = `${usagePct}%`; + } + } + if (fractionEl) { + const cardLabel = quota.format === 'currency' ? '$' + (quota.used || 0).toFixed(2) + ' / $' + (quota.limit || 0).toFixed(2) : (quota.used || 0) + ' / ' + (quota.limit || 0); + fractionEl.textContent = cardLabel; + } + if (statusEl) { + const config = statusConfig[status] || statusConfig.healthy; + statusEl.setAttribute('data-status', status); + statusEl.innerHTML = `${config.label}`; + } + if (resetEl) { + if (quota.resetsAt) { + resetEl.setAttribute('data-reset-at', quota.resetsAt); + resetEl.textContent = formatResetTime(quota.resetsAt); + } else { + resetEl.removeAttribute('data-reset-at'); + resetEl.textContent = ''; + } + } + if (countdownEl) { + if (quota.timeUntilResetSeconds > 0) { + countdownEl.textContent = formatDuration(quota.timeUntilResetSeconds); + countdownEl.classList.toggle('imminent', quota.timeUntilResetSeconds < 1800); + countdownEl.style.display = ''; + } else { + countdownEl.style.display = 'none'; + } + } +} + + async function fetchCurrent() { const requestProvider = getCurrentProvider(); @@ -3935,6 +4075,18 @@ async function fetchCurrent() { updateKimiQuotaCards(data.quotas || [], 'quota-grid-kimi'); } } + } else if (provider === 'opencode') { + if (data.quotas) { + const container = document.getElementById('quota-grid-opencode'); + const hasCards = container && container.querySelector('.opencode-card') !== null; + if (container && !hasCards) { + renderOpenCodeQuotaCards(data.quotas, 'quota-grid-opencode'); + } + if (Array.isArray(data.quotas) && data.quotas.length > 0) { + data.quotas.forEach(q => updateOpenCodeCard(q)); + } + } + } else if (provider === 'zai') { updateCard('tokensLimit', data.tokensLimit); updateCard('timeLimit', data.timeLimit); @@ -4937,6 +5089,8 @@ function initChart() { defaultDatasets = []; // Grok datasets are dynamic - populated when history data arrives } else if (provider === 'kimi') { defaultDatasets = []; // Kimi datasets are dynamic - populated when history data arrives + } else if (provider === 'opencode') { + defaultDatasets = []; // OpenCode datasets are dynamic } else if (provider === 'zai') { defaultDatasets = [ { label: 'Tokens Limit', data: [], borderColor: getComputedStyle(document.documentElement).getPropertyValue('--chart-subscription').trim() || '#0D9488', backgroundColor: 'rgba(13, 148, 136, 0.06)', fill: true, tension: 0.4, borderWidth: 2, pointRadius: 0, pointHoverRadius: 4, hidden: State.hiddenQuotas.has('tokensLimit') }, @@ -4964,6 +5118,8 @@ function initChart() { ? [] : provider === 'kimi' ? [] + : provider === 'opencode' + ? [] : provider === 'api-integrations' ? [] : ['subscription', 'search', 'toolCalls']; @@ -5492,6 +5648,34 @@ async function fetchHistory(range) { State.chart.update(); return; } + if (provider === 'opencode') { + const flattenedRows = historyRows.map(row => { + const flat = { capturedAt: row.capturedAt }; + if (Array.isArray(row.quotas)) { + row.quotas.forEach(q => { flat[q.name] = q.utilization; }); + } + return flat; + }); + const style = getComputedStyle(document.documentElement); + const datasets = []; + const configs = [ + { label: '5-Hour Limit', key: 'five_hour', hiddenKey: 'five_hour', color: style.getPropertyValue('--chart-subscription').trim() || '#0D9488', bg: 'rgba(13, 148, 136, 0.06)' }, + { label: 'Weekly Limit', key: 'weekly', hiddenKey: 'weekly', color: style.getPropertyValue('--chart-search').trim() || '#F59E0B', bg: 'rgba(245, 158, 11, 0.06)' }, + { label: 'Monthly Limit', key: 'monthly', hiddenKey: 'monthly', color: style.getPropertyValue('--chart-toolcalls').trim() || '#3B82F6', bg: 'rgba(59, 130, 246, 0.06)' } + ]; + configs.forEach(cfg => { + const rawData = flattenedRows.map(d => ({ x: new Date(d.capturedAt), y: d[cfg.key] || 0 })); + const { data, gapSegments, pointRadii } = processDataWithGaps(rawData, range); + datasets.push({ label: cfg.label, data: data, borderColor: cfg.color, backgroundColor: cfg.bg, fill: true, tension: 0.4, borderWidth: 2, pointRadius: pointRadii, pointHoverRadius: 4, hidden: State.hiddenQuotas.has(cfg.hiddenKey), spanGaps: true, segment: getSegmentStyle(gapSegments, cfg.color) }); + }); + State.chart.data.datasets = datasets; + updateTimeScale(State.chart, range); + State.chartYMax = computeYMax(State.chart.data.datasets, State.chart); + State.chart.options.scales.y.max = State.chartYMax; + State.chart.update(); + return; + } + if (provider === 'codex') { // Codex history: array of { capturedAt, five_hour, seven_day, ... } @@ -5580,6 +5764,7 @@ const bothProviderNames = { cursor: 'Cursor', grok: 'Grok', kimi: 'Kimi Code', + opencode: 'OpenCode', 'api-integrations': 'API Integrations', }; @@ -5915,7 +6100,7 @@ function buildAllProviderEntries() { title: bothProviderNames[provider] || toTitleCase(provider), badge: provider === 'copilot' ? 'Beta' - : (provider === 'cursor' + : (provider === 'cursor' || provider === 'opencode' ? (payload.planName || toTitleCase(payload.accountType || '')) : toTitleCase(payload.planType || '')), promoHtml: provider === 'anthropic' && payload.promo ? promoTagHTML() : '', @@ -6452,7 +6637,7 @@ function buildProviderCardDatasets(provider, rows, range) { if (provider === 'gemini') { return buildDynamicDatasetsForRows(rows, range, geminiDisplayNames, geminiChartColorMap, geminiChartColorFallback, 'gemini'); } - if (provider === 'cursor') { + if (provider === 'cursor' || provider === 'opencode') { const normalizedRows = rows.map((row) => { if (!Array.isArray(row.quotas)) return row; const entry = { capturedAt: row.capturedAt }; @@ -6461,7 +6646,9 @@ function buildProviderCardDatasets(provider, rows, range) { }); return entry; }); - return buildDynamicDatasetsForRows(normalizedRows, range, cursorDisplayNames, cursorChartColorMap, cursorChartColorFallback, 'cursor'); + return provider === 'cursor' + ? buildDynamicDatasetsForRows(normalizedRows, range, cursorDisplayNames, cursorChartColorMap, cursorChartColorFallback, 'cursor') + : buildDynamicDatasetsForRows(normalizedRows, range, opencodeDisplayNames, opencodeChartColorMap, opencodeChartColorFallback, 'opencode'); } if (provider === 'openrouter') { const orDisplayNames = { usage: 'Total Usage', usageDaily: 'Daily Usage', percent: 'Usage %' }; @@ -6979,7 +7166,7 @@ async function fetchCycles() { const requestSeq = (State.cyclesRequestSeq || 0) + 1; State.cyclesRequestSeq = requestSeq; const provider = requestProvider; - const loggingHistoryProviders = new Set(['synthetic', 'zai', 'anthropic', 'copilot', 'codex', 'antigravity', 'minimax', 'gemini', 'cursor', 'grok', 'kimi']); + const loggingHistoryProviders = new Set(['synthetic', 'zai', 'anthropic', 'copilot', 'codex', 'antigravity', 'minimax', 'gemini', 'cursor', 'grok', 'kimi', 'opencode']); // All-accounts overview: fetch each account's logging history and merge, // tagging every row with its account name for the combined table. @@ -7173,7 +7360,7 @@ function renderCyclesTable() { const provider = getCurrentProvider(); const quotaNames = State.cyclesQuotaNames; - const usePercent = provider === 'anthropic' || provider === 'copilot' || provider === 'codex' || provider === 'antigravity' || provider === 'minimax' || provider === 'gemini' || provider === 'openrouter' || provider === 'cursor' || provider === 'grok' || provider === 'kimi' || provider === 'moonshot' || provider === 'deepseek'; + const usePercent = provider === 'anthropic' || provider === 'copilot' || provider === 'codex' || provider === 'antigravity' || provider === 'minimax' || provider === 'gemini' || provider === 'openrouter' || provider === 'cursor' || provider === 'grok' || provider === 'kimi' || provider === 'moonshot' || provider === 'deepseek' || provider === 'opencode'; const deltaUsesPercent = usePercent && provider !== 'minimax' && provider !== 'moonshot' && provider !== 'deepseek'; const isLoggingHistory = State.isLoggingHistory === true; const showAccount = isAccountsOverviewMode(provider); @@ -8535,7 +8722,7 @@ function renderOverviewTable() { const quotaNames = State.overviewQuotaNames; const overviewProv = getOverviewProvider(); - const usePercent = overviewProv === 'anthropic' || overviewProv === 'codex' || overviewProv === 'antigravity' || overviewProv === 'minimax' || overviewProv === 'gemini' || overviewProv === 'openrouter' || overviewProv === 'cursor' || overviewProv === 'grok' || overviewProv === 'kimi'; + const usePercent = overviewProv === 'anthropic' || overviewProv === 'codex' || overviewProv === 'antigravity' || overviewProv === 'minimax' || overviewProv === 'gemini' || overviewProv === 'openrouter' || overviewProv === 'cursor' || overviewProv === 'grok' || overviewProv === 'kimi' || overviewProv === 'opencode'; const deltaUsesPercent = usePercent && overviewProv !== 'minimax'; // MiniMax reports a percentage-based quota; the Duration and Total Delta // columns add no signal there, so omit them for this provider. @@ -9642,6 +9829,7 @@ const DEFAULT_PROVIDER_TAB_LABELS = { cursor: 'Cursor', grok: 'Grok', kimi: 'Kimi', + opencode: 'OpenCode', 'api-integrations': 'API Integrations', both: 'All', }; diff --git a/internal/web/static/icons/opencode.svg b/internal/web/static/icons/opencode.svg new file mode 100644 index 00000000..8456d444 --- /dev/null +++ b/internal/web/static/icons/opencode.svg @@ -0,0 +1,4 @@ + + + + diff --git a/internal/web/static/menubar.html b/internal/web/static/menubar.html index b65329bd..78271f0f 100644 --- a/internal/web/static/menubar.html +++ b/internal/web/static/menubar.html @@ -381,6 +381,11 @@ mask-image: url("/static/icons/kimi.svg"); } + .provider-icon-opencode { + -webkit-mask-image: url("/static/icons/opencode.svg"); + mask-image: url("/static/icons/opencode.svg"); + } + .provider-icon-moonshot { -webkit-mask-image: url("/static/icons/moonshot.svg"); mask-image: url("/static/icons/moonshot.svg"); @@ -2722,6 +2727,8 @@

Provider Order

return "provider-icon-grok"; case "kimi": return "provider-icon-kimi"; + case "opencode": + return "provider-icon-opencode"; case "moonshot": return "provider-icon-moonshot"; case "deepseek": diff --git a/internal/web/templates/dashboard.html b/internal/web/templates/dashboard.html index 982006a7..a9ab2c02 100644 --- a/internal/web/templates/dashboard.html +++ b/internal/web/templates/dashboard.html @@ -137,6 +137,9 @@

Dashboard

{{else if eq .CurrentProvider "kimi"}}
+ {{else if eq .CurrentProvider "opencode"}} + +
{{else if eq .CurrentProvider "api-integrations"}}
@@ -491,7 +494,7 @@

{{end}} - {{if and (ne .CurrentProvider "both") (ne .CurrentProvider "api-integrations") (ne .CurrentProvider "cursor")}} + {{if and (ne .CurrentProvider "both") (ne .CurrentProvider "api-integrations") (ne .CurrentProvider "cursor") (ne .CurrentProvider "opencode")}}

@@ -607,7 +610,7 @@

- {{if eq .CurrentProvider "cursor"}}Usage Samples{{else}}Logging History{{end}} + {{if or (eq .CurrentProvider "cursor") (eq .CurrentProvider "opencode")}}Usage Samples{{else}}Logging History{{end}}

@@ -621,7 +624,7 @@

- {{if eq .CurrentProvider "cursor"}}Bucket{{else}}Group{{end}} + {{if or (eq .CurrentProvider "cursor") (eq .CurrentProvider "opencode")}}Bucket{{else}}Group{{end}}
@@ -647,14 +650,14 @@

- {{if eq .CurrentProvider "cursor"}}No usage samples yet. Cursor tracking begins after the first successful sync.{{else}}No polling data yet. Tracking begins on first poll.{{end}} + {{if or (eq .CurrentProvider "cursor") (eq .CurrentProvider "opencode")}}No usage samples yet. Tracking begins after the first successful sync.{{else}}No polling data yet. Tracking begins on first poll.{{end}}

@@ -665,11 +668,11 @@

- {{if eq .CurrentProvider "cursor"}}Billing Cycle Overview{{else}}Cycle Overview{{end}} + {{if or (eq .CurrentProvider "cursor") (eq .CurrentProvider "opencode")}}Billing Cycle Overview{{else}}Cycle Overview{{end}}

- {{if eq .CurrentProvider "cursor"}}Quota{{else}}Period{{end}} + {{if or (eq .CurrentProvider "cursor") (eq .CurrentProvider "opencode")}}Quota{{else}}Period{{end}}