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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 33 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,7 @@ accepted.
| Command | Operations |
| -------------------------- | ----------------------------------------------------------------------------------------------- |
| `kaiten customers` | `list`, `get`, `create`, `update`, `delete` |
| `kaiten instances` | `list`, `get`, `create`, `update`, `delete`, `audit-trails`, `usage list\|get\|report` |
| `kaiten instances` | `list`, `get`, `create`, `update`, `delete`, `audit-trails`, `usage list\|get\|report\|history\|export` |
| `kaiten licenses` | `list`, `get`, `create`, `update`, `delete`, `entitlements list\|get\|associate\|update\|delete` |
| `kaiten entitlements` | `list`, `get`, `create`, `update`, `delete` |
| `kaiten entitlement-groups`| `list`, `get`, `create`, `update`, `delete`, `add-entitlement`, `remove-entitlement`, `usage` |
Expand Down Expand Up @@ -156,6 +156,38 @@ kaiten instances usage list acme-production
kaiten entitlement-groups usage compute acme-production
```

A report without a key is sent once and never retried: if it fails in flight, it may
or may not have been counted. Give it a `--transaction-id` and Kaiten applies it at
most once per key, so the CLI retries it on network errors and on 500, 502, 503 and
504, and running the same command again is safe. A report the server had already
counted is answered from the first time, with a note on stderr; stdout is unchanged.

```shell
kaiten instances usage report acme-production tokens --value 1200 \
--transaction-id llm-call:9f2c:tokens
```

A key already used for a different report exits `5`: send a correction as a new
report under a new key.

The usage history lists every accepted report, with the counter before and after it
and the limit it was gated on. The range defaults to the last 30 days.

```shell
kaiten instances usage history acme-production tokens --from 2026-10-01T00:00:00Z

# Stream it as CSV (default) or NDJSON: one pair, up to 366 days per run...
kaiten instances usage export acme-production tokens > tokens.csv
# ...or the whole organization, up to 31 days per run, narrowed if needed
kaiten instances usage export --from 2026-09-01T00:00:00Z --to 2026-10-01T00:00:00Z \
--format json --output-file usage-september.ndjson
```

`--instance-id` and `--entitlement-id` reach deleted instances and entitlements,
whose reports are kept. A `--from` before the start of the organization's history
exits `5`. Exports are written as they arrive and are not bound by the CLI's request
timeout; `--output-file` only takes its final name once the export is complete.

### Service account tokens

```shell
Expand Down
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ module github.com/kaitencloud/cli
go 1.25.0

require (
github.com/kaitencloud/sdk-go v0.0.1
github.com/kaitencloud/sdk-go v0.0.2-0.20261005202017-deeb018edc00
github.com/spf13/cobra v1.10.2
github.com/spf13/pflag v1.0.10
gopkg.in/yaml.v3 v3.0.1
Expand Down
4 changes: 2 additions & 2 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,8 @@ github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/juju/gnuflag v0.0.0-20171113085948-2ce1bb71843d/go.mod h1:2PavIy+JPciBPrBUjwbNvtwB6RQlve+hkpll6QSNmOE=
github.com/kaitencloud/sdk-go v0.0.1 h1:WfR1qFNG++WkcGu55pOcT1nIt520VPMT0a81aOSj03M=
github.com/kaitencloud/sdk-go v0.0.1/go.mod h1:KE77iZ8i+SfjjhUf+qPaQrLHq3/EpFuz4+UPtZ7WFeM=
github.com/kaitencloud/sdk-go v0.0.2-0.20261005202017-deeb018edc00 h1:kPf8B3ecEZZjLFBikkY57yAI8kvdQn6zvDZ7cz16cDM=
github.com/kaitencloud/sdk-go v0.0.2-0.20261005202017-deeb018edc00/go.mod h1:KE77iZ8i+SfjjhUf+qPaQrLHq3/EpFuz4+UPtZ7WFeM=
github.com/oapi-codegen/runtime v1.3.1 h1:RgDY6J4OGQLbRXhG/Xpt3vSVqYpHQS7hN4m85+5xB9g=
github.com/oapi-codegen/runtime v1.3.1/go.mod h1:kOdeacKy7t40Rclb1je37ZLFboFxh+YLy0zaPCMibPY=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
Expand Down
6 changes: 4 additions & 2 deletions internal/cmd/common.go
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,9 @@ func runtimeConfig(cmd *cobra.Command) (config.Runtime, error) {
return config.Resolve(baseURL, authToken, format)
}

func newClient(cmd *cobra.Command) (*sdk.Client, config.Runtime, error) {
// newClient builds an SDK client from the resolved configuration. extra options
// are applied after the CLI's own, so a command can replace the HTTP client.
func newClient(cmd *cobra.Command, extra ...sdk.Option) (*sdk.Client, config.Runtime, error) {
cfg, err := runtimeConfig(cmd)
if err != nil {
return nil, config.Runtime{}, err
Expand All @@ -81,7 +83,7 @@ func newClient(cmd *cobra.Command) (*sdk.Client, config.Runtime, error) {
opts = append(opts, sdk.WithBearerToken(cfg.AuthToken))
}

client, err := sdk.NewClient(cfg.BaseURL, opts...)
client, err := sdk.NewClient(cfg.BaseURL, append(opts, extra...)...)
if err != nil {
return nil, cfg, err
}
Expand Down
6 changes: 6 additions & 0 deletions internal/cmd/exit.go
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,12 @@ func exitCode(cmd *cobra.Command, err error) int {
return exitRejected
}

// The SDK refuses a malformed --transaction-id before sending anything: the
// invocation was wrong, not the API's answer.
if errors.Is(err, sdk.ErrInvalidTransactionID) {
return exitUsage
}

var netErr net.Error
if errors.As(err, &netErr) || errors.Is(err, context.DeadlineExceeded) {
return exitUnavailable
Expand Down
38 changes: 34 additions & 4 deletions internal/cmd/instances.go
Original file line number Diff line number Diff line change
Expand Up @@ -241,6 +241,8 @@ func newInstancesUsageCommand() *cobra.Command {
cmd.AddCommand(newInstancesUsageListCommand())
cmd.AddCommand(newInstancesUsageGetCommand())
cmd.AddCommand(newInstancesUsageReportCommand())
cmd.AddCommand(newInstancesUsageHistoryCommand())
cmd.AddCommand(newInstancesUsageExportCommand())
return cmd
}

Expand Down Expand Up @@ -304,17 +306,32 @@ func newInstancesUsageReportCommand() *cobra.Command {
var behavior string
var metadataFile string
var metadataPayload string
var transactionID string

cmd := &cobra.Command{
Use: "report <instance-slug> <entitlement-slug> --value <number>",
Short: "Report entitlement usage for an instance",
Args: cobra.ExactArgs(2),
Long: `Report entitlement usage for an instance.

Without --transaction-id a report is sent once: if it fails in flight it may or
may not have been counted, so it is not sent again. With one, Kaiten applies the
report at most once per key, so it is retried on network errors and on 500, 502,
503 and 504, and running the same command again is safe -- a report the server
had already counted is answered from the first time, noted on stderr.

A key already used for a different report is refused (exit code 5): send a
correction as a new report under a new key.`,
Args: cobra.ExactArgs(2),
Example: ` # Add to the recorded usage
kaiten instances usage report acme-prod seats --value 3

# Overwrite it instead, and attach metadata
kaiten instances usage report acme-prod seats --value 12 --behavior set \
--metadata-payload '{"source":"nightly-sync"}'`,
--metadata-payload '{"source":"nightly-sync"}'

# Make it safe to retry: one key per measurement
kaiten instances usage report acme-prod tokens --value 1200 \
--transaction-id llm-call:9f2c:tokens`,
RunE: func(cmd *cobra.Command, args []string) error {
hasInline := anyFlagChanged(cmd, "value", "behavior", "metadata-file", "metadata-payload")
inputValue, err := resolveInput(file, payload, hasInline, func() (sdk.UsageReportInput, error) {
Expand All @@ -335,25 +352,38 @@ func newInstancesUsageReportCommand() *cobra.Command {
if err != nil {
return err
}
// The flag wins over a key in --file or --payload, so one payload file
// can be replayed under per-run keys.
if transactionID != "" {
inputValue.TransactionID = transactionID
}

client, _, err := newClient(cmd)
if err != nil {
return err
}
ctx, cancel := commandContext(cmd)
defer cancel()
item, err := client.Instances.ReportEntitlementUsageMetric(ctx, args[0], args[1], inputValue)
result, err := client.Instances.ReportEntitlementUsage(ctx, args[0], args[1], inputValue)
if err != nil {
return err
}
return writeStructured(cmd, output.FormatYAML, item, output.Table{})
// Notes go to stderr, so stdout stays the usage document scripts read.
if result.Replayed {
fmt.Fprintf(cmd.ErrOrStderr(), "note: transaction %s was already counted; this is its original result, not a new report\n", inputValue.TransactionID)
}
if result.MetadataDropped {
fmt.Fprintln(cmd.ErrOrStderr(), "note: the metadata was larger than 4 KiB and was not stored; the report was counted")
}
return writeStructured(cmd, output.FormatYAML, result.Usage, output.Table{})
},
}
addInputSourceFlags(cmd, &file, &payload, "usage report")
cmd.Flags().Float64Var(&value, "value", 0, "Usage value to report")
cmd.Flags().StringVar(&behavior, "behavior", string(sdk.Append), "Usage behavior: append or set")
cmd.Flags().StringVar(&metadataFile, "metadata-file", "", "Optional JSON or YAML metadata file")
cmd.Flags().StringVar(&metadataPayload, "metadata-payload", "", "Optional inline JSON or YAML metadata object")
cmd.Flags().StringVar(&transactionID, "transaction-id", "", "Idempotency key: 1 to 128 characters of letters, digits, '.', '_', ':' and '-'")
return cmd
}

Expand Down
Loading
Loading