Compare commits

...
Author SHA1 Message Date
hanzo-dev b003982fe0 usage: take the window rule from hanzoai/types
Cloud imported ai/object to resolve ?range/?start/?end into a [start, end) and
a bucket interval. That rule is 30 lines of time arithmetic; ai/object carries
1925 dependencies. It now lives in hanzoai/types, which carries none beyond the
standard library, and ai reads it from the same place, so the two cannot drift.

The interval stays typed as far as the query builder. analytics interpolates it
into toStartOf%s because a bucket function is not bindable; types.Interval
admits only Hour or Day, so that is now safe by construction rather than by the
comment that asserted it. stepOf goes with it — it was the third copy of the
same bucket width, after ai's cloudUsageStep and the one in types.

Each call site used to re-derive the echoed label after the call. ParseWindow
returns it, so the three copies of that defaulting are gone.

This removes no ai/object import: all three packages still reach it for
EnsureCloudUsageTable and the datastore verbs. Sites drop 174 -> 170.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-26 19:26:57 -07:00
6 changed files with 29 additions and 38 deletions
+5 -3
View File
@@ -24,6 +24,7 @@ import (
aiobject "github.com/hanzoai/ai/object"
"github.com/hanzoai/cloud"
"github.com/hanzoai/cloud/clients/admin/core"
"github.com/hanzoai/types"
"github.com/zap-proto/zip"
)
@@ -174,11 +175,12 @@ func fundingClass(pc ProviderCredit) string {
// usage split by funding class over the window (default last 30d). SuperAdmin-guarded.
func UsageFunding(s *cloud.Service[core.State], c *zip.Ctx) error {
ctx := c.Context()
start, end, _, werr := aiobject.ResolveCloudUsageWindow("", c.Query("from"), c.Query("to"), time.Now().UTC())
w, werr := types.ParseWindow("", c.Query("from"), c.Query("to"), time.Now().UTC())
if werr != nil {
end = time.Now().UTC()
start = end.AddDate(0, 0, -30)
w.End = time.Now().UTC()
w.Start = w.End.AddDate(0, 0, -30)
}
start, end := w.Start, w.End
cls := map[string]string{}
for _, pc := range computeProviderCredits(ctx, s) {
+11 -14
View File
@@ -65,6 +65,7 @@ import (
"github.com/hanzoai/cloud"
"github.com/hanzoai/cloud/clients/principal"
"github.com/hanzoai/cloud/clients/sites"
"github.com/hanzoai/types"
"github.com/zap-proto/zip"
)
@@ -184,18 +185,14 @@ func tenant(c *zip.Ctx) (string, bool) {
}
// window resolves the [start,end) window + bucket interval from ?range/?start/?end,
// reusing ai/object.ResolveCloudUsageWindow so analytics and the console Overview
// share ONE window grammar (24h|7d|30d|custom). A bad range is a 400.
func window(c *zip.Ctx) (time.Time, time.Time, string, string, error) {
rangeLabel := strings.TrimSpace(c.Query("range"))
start, end, interval, err := aiobject.ResolveCloudUsageWindow(rangeLabel, c.Query("start"), c.Query("end"), time.Now())
// reusing types.ParseWindow so analytics and the console Overview share ONE
// window grammar (24h|7d|30d|custom). A bad range is a 400.
func window(c *zip.Ctx) (time.Time, time.Time, types.Interval, string, error) {
w, err := types.ParseWindow(c.Query("range"), c.Query("start"), c.Query("end"), time.Now())
if err != nil {
return time.Time{}, time.Time{}, "", "", zip.ErrBadRequest(err.Error())
}
if rangeLabel == "" {
rangeLabel = "24h"
}
return start, end, interval, rangeLabel, nil
return w.Start, w.End, w.Interval, w.Label, nil
}
// requireDatastore returns the honest 503 when the datastore ledger is not
@@ -311,7 +308,7 @@ func overview(s *cloud.Service[state], c *zip.Ctx) error {
Range: rangeLabel,
Start: start.UTC().Format(time.RFC3339),
End: end.UTC().Format(time.RFC3339),
Interval: interval,
Interval: string(interval),
Scope: Scope{Org: org},
LLM: llm,
Web: buildWebOverview(erow, eventsOK),
@@ -338,10 +335,10 @@ func timeseries(s *cloud.Service[state], c *zip.Ctx) error {
return zip.Errorf(http.StatusServiceUnavailable, "analytics warehouse unavailable: %v", err)
}
// bucketFn is a CLOSED server-chosen enum (never user input), so interpolating
// it is injection-safe; the org + time bounds stay bound parameters.
// interval admits only Hour or Day, so interpolating the bucket function is
// safe by construction; the org + time bounds stay bound parameters.
bucketFn := "Hour"
if interval == "day" {
if interval == types.Day {
bucketFn = "Day"
}
where, args := llmWhere(org, start, end)
@@ -357,7 +354,7 @@ func timeseries(s *cloud.Service[state], c *zip.Ctx) error {
Range: rangeLabel,
Start: start.UTC().Format(time.RFC3339),
End: end.UTC().Format(time.RFC3339),
Interval: interval,
Interval: string(interval),
Scope: Scope{Org: org},
Series: buildSeries(start, end, interval, rows),
Source: llmTable,
+3 -9
View File
@@ -28,6 +28,7 @@ package analytics
import (
"encoding/json"
"fmt"
"github.com/hanzoai/types"
"math"
"sort"
"strconv"
@@ -299,8 +300,8 @@ func buildCommerceOverview(row map[string]any, ok bool) CommerceOverview {
// series so the client charts a continuous line. Bucket alignment matches
// toStartOf{Hour,Day}(…, 'UTC'): Go's Truncate over the step lands on the same
// UTC boundaries. Pure (mirrors ai/object buildCloudUsageSeries).
func buildSeries(start, end time.Time, interval string, rows []map[string]any) []SeriesPoint {
step := stepOf(interval)
func buildSeries(start, end time.Time, interval types.Interval, rows []map[string]any) []SeriesPoint {
step := interval.Step()
type agg struct{ requests, tokens, spend int64 }
idx := make(map[int64]agg, len(rows))
@@ -326,13 +327,6 @@ func buildSeries(start, end time.Time, interval string, rows []map[string]any) [
return out
}
func stepOf(interval string) time.Duration {
if strings.EqualFold(interval, "day") {
return 24 * time.Hour
}
return time.Hour
}
// buildTopModels assembles the top-models table, computing each model's share of
// total spend. Rows arrive already ordered by the query, but we sort defensively
// so the pct/ordering is correct regardless of driver row order. Pure.
+7 -12
View File
@@ -58,6 +58,7 @@ import (
"github.com/hanzoai/cloud"
"github.com/hanzoai/cloud/clients/commerceinproc"
"github.com/hanzoai/cloud/clients/principal"
"github.com/hanzoai/types"
"github.com/zap-proto/zip"
)
@@ -128,18 +129,15 @@ func summary(s *cloud.Service[state], c *zip.Ctx) error {
if !ok {
return zip.ErrUnauthorized("sign in to view usage")
}
rangeLabel := strings.TrimSpace(c.Query("range"))
start, end, interval, err := aiobject.ResolveCloudUsageWindow(rangeLabel, c.Query("start"), c.Query("end"), time.Now())
w, err := types.ParseWindow(c.Query("range"), c.Query("start"), c.Query("end"), time.Now())
if err != nil {
return zip.ErrBadRequest(err.Error())
}
if rangeLabel == "" {
rangeLabel = "24h"
}
rangeLabel, start, end, interval := w.Label, w.Start, w.End, w.Interval
ctx := c.Context()
// ── Spend (commerce) ── best-effort; unconfigured/unreachable → honest zeros.
spend := buildSpendBlock(s, ctx, org, start, end, interval)
spend := buildSpendBlock(s, ctx, org, start, end, string(interval))
// ── LLM totals (warehouse) ── honest-empty when the datastore is not connected.
llm, warehouseOK := buildLLMBlock(s, ctx, org, start, end)
@@ -154,7 +152,7 @@ func summary(s *cloud.Service[state], c *zip.Ctx) error {
Range: rangeLabel,
Start: start.UTC().Format(time.RFC3339),
End: end.UTC().Format(time.RFC3339),
Interval: interval,
Interval: string(interval),
Scope: Scope{Org: org, User: user},
Spend: spend,
LLM: llm,
@@ -216,15 +214,12 @@ func analytics(s *cloud.Service[state], c *zip.Ctx) error {
return zip.Errorf(http.StatusPaymentRequired, "analytics datastore is a paid feature — upgrade at console.hanzo.ai")
}
rangeLabel := strings.TrimSpace(c.Query("range"))
now := time.Now()
start, end, _, werr := aiobject.ResolveCloudUsageWindow(rangeLabel, c.Query("start"), c.Query("end"), now)
w, werr := types.ParseWindow(c.Query("range"), c.Query("start"), c.Query("end"), now)
if werr != nil {
return zip.ErrBadRequest(werr.Error())
}
if rangeLabel == "" {
rangeLabel = "24h"
}
rangeLabel, start, end := w.Label, w.Start, w.End
// Clamp the window to the plan's retention entitlement: a tenant may never read
// older than access.RetentionDays, even with a custom ?start.
if floor := now.Add(-time.Duration(access.RetentionDays) * 24 * time.Hour); start.Before(floor) {
+1
View File
@@ -801,6 +801,7 @@ exclude github.com/ugorji/go v0.0.0-20171122102828-84cb69a8af83
// --- /v1/deploy engine embed (argo gitops-engine, in-process) ---
require (
github.com/hanzoai/deploy/gitops-engine v0.7.2
github.com/hanzoai/types v0.1.0
k8s.io/kubernetes v1.35.3 // indirect
)
+2
View File
@@ -1150,6 +1150,8 @@ github.com/hanzoai/tasks v1.51.4 h1:qMx80ZfiJPQIDd9lpxxJhDywKYnHOmKxL2IfhZfExgk=
github.com/hanzoai/tasks v1.51.4/go.mod h1:+Oqq+L3nY4uKeLyPanimlbcym/HoIUDaBezgIs6v4Hk=
github.com/hanzoai/thinking v0.1.1 h1:JuJ9s3q53NRSHSU+/zEdg089vppuNUKK/9BG7bairLw=
github.com/hanzoai/thinking v0.1.1/go.mod h1:2lkNDDi+7mAipRhZZ57uqoqrzLKGBdoU5Rb6fGSOoR4=
github.com/hanzoai/types v0.1.0 h1:hxPueqy2QnVF3CbvAZ9CJE26pibT/Cb8ZYPhH/CNPwA=
github.com/hanzoai/types v0.1.0/go.mod h1:3SEt7J/c5X3C/Uv9n9a+/U9+FGkMQwDSiHwENPFxQW4=
github.com/hanzoai/vfs v0.6.6 h1:+4qrHjDOIqgUN1wryU9b5xphDKzTVyBa4CEwbhFSlYw=
github.com/hanzoai/vfs v0.6.6/go.mod h1:OiiFWLgrTCMAJimUEgoSy1b/qXHpN34PN3RQwE49e1U=
github.com/hanzoai/xorm v1.4.4 h1:2VRwh5BtOgbED+CAzHQ47sPZBgljBlKnJw1Ar6V6il0=