From b35d8ce2ebe4782aecada05b70d83222c5aa1349 Mon Sep 17 00:00:00 2001 From: tomflenner Date: Sat, 24 Oct 2026 19:17:22 +0200 Subject: [PATCH 1/3] feat(ai): add safe CLI catalogue and entitlement accounting commands Signed-off-by: tomflenner (cherry picked from commit c0969d2b386f3361c48e1aa6b24c940bfc963084) --- README.md | 66 ++++++ internal/cmd/ai.go | 435 ++++++++++++++++++++++++++++++++++++++++ internal/cmd/ai_test.go | 353 ++++++++++++++++++++++++++++++++ internal/cmd/root.go | 1 + 4 files changed, 855 insertions(+) create mode 100644 internal/cmd/ai.go create mode 100644 internal/cmd/ai_test.go diff --git a/README.md b/README.md index 4673f15..727670e 100644 --- a/README.md +++ b/README.md @@ -100,6 +100,7 @@ accepted. | `kaiten service-accounts` | `list`, `get`, `create`, `update`, `tokens list\|create\|delete` | | `kaiten deployment-zones` | `list`, `get`, `create`, `update`, `delete` | | `kaiten billing` | `settings`, `capabilities`, `health`, `sync`, `close-periods`, `handoff`, `invoices`, `subscriptions`, `prices`, `addons`, `instance-addons`, `vouchers`, `publishable-keys`, `customer-sessions`, `customers` | +| `kaiten ai` | `models list\|get\|create\|update\|delete`, `conversion-rules list\|get\|create\|update\|archive`, `report`, `inferences list`, `reservations list` | | `kaiten config` | `set`, `unset`, `view` | | `kaiten doctor` | Validate configuration and API connectivity | | `kaiten version` | Print version, commit and build date | @@ -214,6 +215,71 @@ kaiten billing instance-addons set-quantity acme-prod extra-seats --quantity 5 Amounts are printed in the currency's minor units, as the API carries them. An acknowledgement with `--lease-id` for an invoice another claim took since exits `5`. +### AI credit control plane and accounting + +The AI commands manage the model catalogue and credit conversion rules, and +record or inspect usage accounting. They do not configure provider credentials or +execute inference. Conversion rules belong to a **specific license version slug** +(for example `team-v2`), not its license family. + +```shell +kaiten ai models list --vendor acme --modality TEXT --output json +kaiten ai models get assistant-v1 +kaiten ai models create --payload \ + '{"slug":"assistant-v1","name":"Assistant","vendor":"acme","modality":"TEXT","status":"ACTIVE","vendorCosts":{"currency":"USD","perInputToken":"0.000000000001"}}' +kaiten ai models update assistant-v1 --payload '{"status":"DEPRECATED"}' +kaiten ai models delete assistant-v1 --yes +kaiten ai conversion-rules list team-v2 --entitlement ai-credits +kaiten ai conversion-rules create team-v2 --payload \ + '{"entitlementSlug":"ai-credits","modelSlug":"assistant-v1","rates":{"perInputToken":"0.000001","perOutputToken":"0.000002"}}' +kaiten ai conversion-rules update team-v2 rule-123 --file rule.yaml +kaiten ai conversion-rules archive team-v2 rule-123 --yes + +# Reserve estimated quantities, then commit actual quantities under the SAME identity. +# These only record accounting; invoke your provider separately. +kaiten ai report acme-prod ai-credits --payload \ + '{"value":{"type":"ai_usage","phase":"reserve","transactionId":"call-123","model":"assistant-v1","requestType":"COMPLETION","usage":{"inputTokens":100,"outputTokens":50}}}' +kaiten ai report acme-prod ai-credits --payload \ + '{"value":{"type":"ai_usage","phase":"commit","transactionId":"call-123","model":"assistant-v1","requestType":"COMPLETION","requestStatus":"SUCCESS","usage":{"inputTokens":120,"outputTokens":60}}}' +kaiten ai inferences list acme-prod ai-credits --transaction-id call-123 --output json +kaiten ai reservations list acme-prod ai-credits +``` + +Writes take exactly one `--file` (JSON/YAML, `-` for stdin) or `--payload`. +Model slugs are immutable; an update may omit `slug` or repeat the targeted slug. +Vendor costs, conversion rates, credit bounds and fractional `usage.units` must be +**quoted decimal strings**, including in YAML; they are never converted to +floating-point numbers. + +Update commands first GET the current resource, then merge the authored patch +into a writable-field-only PUT. Model updates preserve omitted fields and merge +individual `vendorCosts` dimensions and metadata keys. Slug and modality are +immutable. Rule updates preserve omitted selectors, credit caps and error policy; +`rates` is required and replaces the entire rates map. Entitlement, model and +request-type selectors are immutable: omit them or echo their current values. +An explicit `maximumCreditsPerRequest: null` clears the maximum cap; it does not +permit clearing selectors. + +The API has no compare-and-swap/conditional update contract. Another writer +between the GET and PUT can be overwritten; coordinate concurrent changes to the +same model or conversion rule. + +AI usage read values remain numeric credit counters, with an optional `ai` outcome +on report responses; `ai_usage` is calculation input, not a read-value type. + +AI reports require `value.transactionId` and `phase` (`reserve` or `commit`), with +`behavior` omitted or `append`. No `Idempotency-Key` header is sent. Each command +sends once: after an uncertain failure, rerun the **identical phase and body** with +the same transaction identity. Do not start the provider call after a refused or +uncertain reserve. Numeric adjustments/refunds use `instances usage report` with +`--behavior append`, not `ai report`. + +Model and inference lists return **one page**. JSON/YAML (also the default/table +fallback) preserve the page envelope and cursor; use `--cursor` and `--limit` to +continue. NDJSON emits one record per item, without the page envelope. Inference +lists accept `--from`, `--to`, `--status` and `--transaction-id`; reservations show +open holds only. + ### Service account tokens ```shell diff --git a/internal/cmd/ai.go b/internal/cmd/ai.go new file mode 100644 index 0000000..cec11b4 --- /dev/null +++ b/internal/cmd/ai.go @@ -0,0 +1,435 @@ +package cmd + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "reflect" + "strings" + + "github.com/kaitencloud/cli/internal/output" + "github.com/kaitencloud/sdk-go" + "github.com/spf13/cobra" + "gopkg.in/yaml.v3" +) + +// The pinned SDK predates the AI control-plane endpoints. Keep this bridge local +// until those operations are available in a released SDK. Reports record usage +// only: this command never holds provider keys or executes inference. +func newAICommand() *cobra.Command { + models := aiCall{ + use: "list", short: "List one page of the model catalogue", + path: func([]string) string { return "/models" }, + filters: map[string]string{"cursor": "cursor", "status": "status", "vendor": "vendor", "modality": "modality"}, + maxLimit: 200, + }.command() + modelPath := func(args []string) string { + path := "/models" + if len(args) > 0 { + path += "/" + url.PathEscape(args[0]) + } + return path + } + rulesPath := func(args []string) string { + path := "/licenses/" + url.PathEscape(args[0]) + "/conversion-rules" + if len(args) > 1 { + path += "/" + url.PathEscape(args[1]) + } + return path + } + inferences := aiCall{ + use: "list ", short: "List one page of AI inference accounting records", + path: aiUsagePath("inferences"), + filters: map[string]string{"from": "from", "to": "to", "status": "status", "transaction-id": "transactionId", "cursor": "cursor"}, + maxLimit: 500, + }.command() + return groupCommand("ai", "Manage AI credits and report or inspect accounting (no inference execution)", + aiCall{use: "report ", short: "Record an AI reserve or commit from an authored usage report", + path: aiUsagePath(""), method: http.MethodPost, report: true}.command(), + groupCommand("models", "Manage the model catalogue (no provider credentials)", models, + aiCall{use: "get ", short: "Show a model", path: modelPath}.command(), + aiCall{use: "create", short: "Create a catalogue model from JSON or YAML", path: modelPath, method: http.MethodPost, model: true}.command(), + aiCall{use: "update ", short: "Update a catalogue model without changing its slug", path: modelPath, method: http.MethodPut, model: true}.command(), + aiCall{use: "delete ", short: "Delete a catalogue model", path: modelPath, method: http.MethodDelete, model: true}.command()), + groupCommand("conversion-rules", "Manage conversion rules of a specific license version (not a license family)", + aiCall{use: "list ", short: "List a license version's conversion rules", path: rulesPath, + filters: map[string]string{"status": "status", "entitlement": "entitlementSlug", "model": "modelSlug"}}.command(), + aiCall{use: "get ", short: "Show a conversion rule", path: rulesPath}.command(), + aiCall{use: "create ", short: "Create a conversion rule from JSON or YAML", path: rulesPath, method: http.MethodPost}.command(), + aiCall{use: "update ", short: "Update a conversion rule from JSON or YAML", path: rulesPath, method: http.MethodPut}.command(), + aiCall{use: "archive ", short: "Archive a conversion rule", path: rulesPath, method: http.MethodDelete}.command()), + groupCommand("inferences", "Read inference accounting records", inferences), + groupCommand("reservations", "Read open AI credit reservations", + aiCall{use: "list ", short: "Show open reservations and reserved credits", path: aiUsagePath("reservations")}.command()), + ) +} + +func aiUsagePath(resource string) func([]string) string { + suffix := "" + if resource != "" { + suffix = "/" + resource + } + return func(args []string) string { + return "/instances/" + url.PathEscape(args[0]) + "/entitlements/" + url.PathEscape(args[1]) + "/usage" + suffix + } +} + +type aiCall struct { + use, short, method string + path func([]string) string + filters map[string]string + maxLimit int + report, model bool +} + +// JSON numbers retain their original decimal text; YAML integer counters are +// decoded as integers. This avoids rounding usage quantities through float64. +type aiDocument map[string]any + +func (d *aiDocument) UnmarshalJSON(data []byte) error { + decoder := json.NewDecoder(bytes.NewReader(data)) + decoder.UseNumber() + return decoder.Decode((*map[string]any)(d)) +} + +func (d *aiDocument) UnmarshalYAML(node *yaml.Node) error { + var value map[string]any + if err := node.Decode(&value); err != nil { + return err + } + *d = value + return nil +} + +func (a aiCall) command() *cobra.Command { + var file, payload string + var limit int + cmd := &cobra.Command{ + Use: a.use, Short: a.short, Args: cobra.ExactArgs(positionalCount(a.use)), + RunE: func(cmd *cobra.Command, args []string) error { + query := url.Values{} + for flag, name := range a.filters { + if !flagChanged(cmd, flag) { + continue + } + value, _ := cmd.Flags().GetString(flag) + if flag == "from" || flag == "to" { + if _, err := parseTimeFlag(value, flag); err != nil { + return err + } + } + query.Set(name, value) + } + if flagChanged(cmd, "limit") { + if limit < 1 || limit > a.maxLimit { + return errUsagef("--limit must be between 1 and %d", a.maxLimit) + } + query.Set("limit", fmt.Sprint(limit)) + } + var body []byte + if a.method == http.MethodPost || a.method == http.MethodPut { + in, err := resolveFileOrPayload[aiDocument](file, payload) + if err != nil { + return errUsage(err) + } + if a.report { + if err := validateAIReport(in); err != nil { + return err + } + } else if a.model { + if err := validateAIModel(in, args); err != nil { + return err + } + } else if err := validateAIRuleDecimals(in); err != nil { + return err + } + if a.method == http.MethodPut { + in, err = a.mergeUpdate(cmd, args, in) + if err != nil { + return err + } + } + body, err = json.Marshal(in) + if err != nil { + return errUsage(err) + } + } + if a.method == http.MethodDelete { + action := "archive AI conversion rule" + if a.model { + action = "delete AI catalogue model" + } + if err := confirmDestructive(cmd, action, strings.Join(args, " ")); err != nil { + return err + } + } + return callAIControlPlane(cmd, a.method, a.path(args), query, body) + }, + } + for flag, name := range a.filters { + cmd.Flags().String(flag, "", "Filter or page by "+name) + } + if a.maxLimit > 0 { + cmd.Flags().IntVar(&limit, "limit", 0, fmt.Sprintf("Page size (1 to %d); use --cursor for the next page", a.maxLimit)) + } + if a.method == http.MethodPost || a.method == http.MethodPut { + noun := "conversion rule" + if a.report { + noun = "AI usage report" + } else if a.model { + noun = "catalogue model" + } + addInputSourceFlags(cmd, &file, &payload, noun) + } + if a.method == http.MethodDelete { + addConfirmFlag(cmd) + } + return cmd +} + +// Update commands expose patch semantics over replacement PUT endpoints. A fresh +// GET avoids resetting omitted fields to API defaults. There is no CAS contract: +// a concurrent writer between this GET and PUT can still be overwritten. +func (a aiCall) mergeUpdate(cmd *cobra.Command, args []string, patch aiDocument) (aiDocument, error) { + fields := []string{"entitlementSlug", "modelSlug", "requestType", "rates", "minimumCreditsPerRequest", "maximumCreditsPerRequest", "chargeOnError"} + identities := []string{"entitlementSlug", "modelSlug", "requestType"} + if a.model { + fields = []string{"slug", "name", "vendor", "modality", "status", "description", "externalIds", "metadata", "vendorCosts"} + identities = []string{"slug", "modality"} + } + for key := range patch { + allowed := false + for _, field := range fields { + if key == field { + allowed = true + break + } + } + if !allowed { + return nil, errUsagef("%s is not a writable field for this update", key) + } + } + data, _, err := requestAIControlPlane(cmd, http.MethodGet, a.path(args), nil, nil) + if err != nil { + return nil, err + } + var current aiDocument + if err := json.Unmarshal(data, ¤t); err != nil || current == nil { + return nil, fmt.Errorf("decode current AI resource: expected a JSON object") + } + for _, field := range identities { + if value, present := patch[field]; present && !reflect.DeepEqual(value, current[field]) { + return nil, errUsagef("%s is immutable; omit it or echo the current value", field) + } + } + merged := aiDocument{} + for _, field := range fields { + if value, present := current[field]; present { + merged[field] = value + } + } + for field, value := range patch { + if a.model && (field == "vendorCosts" || field == "metadata") { + if changes, ok := value.(map[string]any); ok { + combined := map[string]any{} + if existing, ok := current[field].(map[string]any); ok { + for key, value := range existing { + combined[key] = value + } + } + for key, value := range changes { + combined[key] = value + } + merged[field] = combined + continue + } + } + // Rule rates intentionally replace the whole map. Explicit null for the + // maximum cap is retained; identities have already been checked above. + merged[field] = value + } + return merged, nil +} + +func validateAIModel(in aiDocument, args []string) error { + if in == nil { + return errUsagef("model must be an object") + } + if len(args) > 0 { + if slug, present := in["slug"]; present && slug != args[0] { + return errUsagef("model slug is immutable: payload slug must match %s or be omitted", args[0]) + } + } + if value, present := in["vendorCosts"]; present { + costs, ok := value.(map[string]any) + if !ok { + return errUsagef("vendorCosts must be an object with quoted decimal strings") + } + for name, cost := range costs { + if _, ok := cost.(string); !ok { + return errUsagef("vendorCosts.%s must be a quoted string", name) + } + } + } + return nil +} + +func validateAIReport(in aiDocument) error { + value, ok := in["value"].(map[string]any) + if !ok || value["type"] != "ai_usage" { + return errUsagef("AI report requires value.type ai_usage; numeric adjustments use instances usage report") + } + if value["phase"] != "reserve" && value["phase"] != "commit" { + return errUsagef("AI report value.phase must be reserve or commit") + } + transactionID, _ := value["transactionId"].(string) + if strings.TrimSpace(transactionID) == "" { + return errUsagef("AI report requires value.transactionId; use the same identity for reserve and commit") + } + if behavior, present := in["behavior"]; present && behavior != "append" { + return errUsagef("AI report behavior must be omitted or append") + } + if usage, ok := value["usage"].(map[string]any); ok { + if units, present := usage["units"]; present { + if _, ok := units.(string); !ok { + return errUsagef("value.usage.units must be a quoted decimal string") + } + } + } + return nil +} + +// Rates and credit bounds are decimal strings on the wire, never float64. +// Reject unquoted YAML/JSON numbers rather than silently rounding them. +func validateAIRuleDecimals(in map[string]any) error { + if in == nil { + return errUsagef("conversion rule must be an object") + } + rates, ok := in["rates"].(map[string]any) + if !ok { + return errUsagef("conversion rule requires a rates object with quoted decimal strings") + } + for name, value := range rates { + if value == nil { + continue + } + if _, ok := value.(string); !ok { + return errUsagef("rates.%s must be a quoted decimal string", name) + } + } + for _, name := range []string{"minimumCreditsPerRequest", "maximumCreditsPerRequest"} { + if value, present := in[name]; present && !(name == "maximumCreditsPerRequest" && value == nil) { + if _, ok := value.(string); !ok { + return errUsagef("%s must be a quoted decimal string", name) + } + } + } + return nil +} + +func callAIControlPlane(cmd *cobra.Command, method, path string, query url.Values, body []byte) error { + data, headers, err := requestAIControlPlane(cmd, method, path, query, body) + if err != nil { + return err + } + if method == http.MethodDelete && len(data) == 0 { + message := "Conversion rule archived" + if strings.HasPrefix(path, "/models/") { + message = "Catalogue model deleted" + } + return writeMessage(cmd, message) + } + if headers.Get("Idempotent-Replayed") == "true" { + fmt.Fprintln(cmd.ErrOrStderr(), "note: this AI transaction phase was already recorded; this is its original result") + } + cfg, err := runtimeConfig(cmd) + if err != nil { + return err + } + return writeAIResponse(cmd, cfg.Output, data) +} + +func requestAIControlPlane(cmd *cobra.Command, method, path string, query url.Values, body []byte) ([]byte, http.Header, error) { + _, cfg, err := newClient(cmd) + if err != nil { + return nil, nil, err + } + if method == "" { + method = http.MethodGet + } + // Concatenating the escaped operation path preserves the configured /api + // prefix; ResolveReference with an absolute operation path would drop it. + endpoint := strings.TrimRight(cfg.BaseURL, "/") + path + if len(query) > 0 { + endpoint += "?" + query.Encode() + } + ctx, cancel := commandContext(cmd) + defer cancel() + req, err := http.NewRequestWithContext(ctx, method, endpoint, bytes.NewReader(body)) + if err != nil { + return nil, nil, errUsage(err) + } + if cfg.AuthToken != "" { + req.Header.Set("Authorization", "Bearer "+cfg.AuthToken) + } + req.Header.Set("Accept", "application/json") + if body != nil { + req.Header.Set("Content-Type", "application/json") + } + // Do not forward credentials or replay management writes through redirects. + client := &http.Client{CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }} + resp, err := client.Do(req) + if err != nil { + return nil, nil, err + } + defer func() { _ = resp.Body.Close() }() + data, err := io.ReadAll(resp.Body) + if err != nil { + return nil, nil, err + } + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + apiErr := &sdk.Error{StatusCode: resp.StatusCode, Status: resp.Status, Body: data} + var problem sdk.ErrorModel + if json.Unmarshal(data, &problem) == nil { + apiErr.Problem = &problem + } + return nil, nil, apiErr + } + return data, resp.Header, nil +} + +func writeAIResponse(cmd *cobra.Command, format string, data []byte) error { + format = strings.TrimSpace(format) + if !json.Valid(data) { + return fmt.Errorf("decode AI response: expected JSON") + } + if format == output.FormatJSON { + return output.Write(cmd.OutOrStdout(), format, json.RawMessage(data), output.Table{}) + } + if format == output.FormatNDJSON { + // JSON/YAML preserve page cursors. NDJSON prints just one document per + // item, matching the CLI's list-output convention. + var page struct { + Items json.RawMessage `json:"items"` + } + var items []json.RawMessage + if json.Unmarshal(data, &page) == nil && page.Items != nil { + if err := json.Unmarshal(page.Items, &items); err != nil { + return fmt.Errorf("decode AI response items: %w", err) + } + } else if json.Unmarshal(data, &items) != nil { + return output.Write(cmd.OutOrStdout(), format, json.RawMessage(data), output.Table{}) + } + return output.Write(cmd.OutOrStdout(), format, items, output.Table{}) + } + // Parsing JSON as a YAML node retains scalar types and exact numeric text, + // unlike decoding through interface{} (float64) or json.Number (YAML string). + var node yaml.Node + if err := yaml.Unmarshal(data, &node); err != nil { + return fmt.Errorf("decode AI response: %w", err) + } + return output.Write(cmd.OutOrStdout(), output.FormatYAML, &node, output.Table{}) +} diff --git a/internal/cmd/ai_test.go b/internal/cmd/ai_test.go new file mode 100644 index 0000000..ae77006 --- /dev/null +++ b/internal/cmd/ai_test.go @@ -0,0 +1,353 @@ +package cmd + +import ( + "encoding/json" + "net/http" + "strings" + "testing" +) + +const currentAIModel = `{"id":"model-id","slug":"assistant-v1","name":"Assistant","vendor":"acme","modality":"TEXT","status":"ACTIVE","description":"keep me","externalIds":["provider/model"],"metadata":{"keep":"metadata","exact":9007199254740993},"vendorCosts":{"currency":"USD","perInputToken":"0.000000000002","perOutputToken":"0.000000000003"},"createdAt":"2026-01-01T00:00:00Z","updatedAt":"2026-01-02T00:00:00Z"}` +const currentAIRule = `{"id":"rule-1","licenseSlug":"team-v2","entitlementSlug":"credits","modelSlug":"assistant-v1","requestType":"COMPLETION","rates":{"perInputToken":"0.1","perOutputToken":"0.2"},"minimumCreditsPerRequest":"1.000001","maximumCreditsPerRequest":"10.000001","chargeOnError":"ALWAYS","status":"ACTIVE","grantedByLicense":true,"createdAt":"2026-01-01T00:00:00Z"}` + +func TestAIModelsReadsOnePageWithFiltersAndBasePrefix(t *testing.T) { + server, api := givenUsageAPI(t, func(w http.ResponseWriter, r *http.Request, _ []byte) { + if r.Header.Get("Authorization") != "Bearer test-token" { + t.Errorf("missing bearer token") + } + writeTestJSON(w, http.StatusOK, `{"items":[{"slug":"model-a"}],"hasMore":true,"nextCursor":"next-1"}`) + }) + stdout, stderr, code := runCapturing(t, "--base-url", server.URL+"/api", "--auth-token", "test-token", "--output", "json", "ai", "models", "list", "--cursor", "first", "--limit", "20", "--vendor", "acme", "--status", "ACTIVE", "--modality", "TEXT") + if code != exitOK { + t.Fatalf("exit %d: %s", code, stderr) + } + r := api.only(t) + if r.method != http.MethodGet || r.path != "/api/models" || r.query.Get("cursor") != "first" || r.query.Get("limit") != "20" || r.query.Get("vendor") != "acme" || r.query.Get("modality") != "TEXT" { + t.Fatalf("request = %+v", r) + } + expectOutputContains(t, stdout, `"nextCursor": "next-1"`) +} + +func TestAIConversionRuleUpdatePreservesDecimalsAndNull(t *testing.T) { + server, api := givenUsageAPI(t, func(w http.ResponseWriter, r *http.Request, body []byte) { + if r.Method == http.MethodGet { + writeTestJSON(w, http.StatusOK, currentAIRule) + return + } + writeTestJSON(w, http.StatusOK, string(body)) + }) + for _, payload := range []string{ + `{"rates":{"perInputToken":"0.000001"},"minimumCreditsPerRequest":"999999999999.123456","maximumCreditsPerRequest":null,"modelSlug":"assistant-v1"}`, + "rates:\n perInputToken: '0.000001'\nminimumCreditsPerRequest: '999999999999.123456'\nmaximumCreditsPerRequest: null\nmodelSlug: assistant-v1", + } { + stdout, stderr, code := runCapturing(t, "--base-url", server.URL, "--output", "json", "ai", "conversion-rules", "update", "team-v2", "rule-1", "--payload", payload) + if code != exitOK { + t.Fatalf("exit %d: %s", code, stderr) + } + for _, want := range []string{`"minimumCreditsPerRequest": "999999999999.123456"`, `"modelSlug": "assistant-v1"`, `"maximumCreditsPerRequest": null`, `"chargeOnError": "ALWAYS"`} { + expectOutputContains(t, stdout, want) + } + } + if api.count() != 4 { + t.Fatalf("requests = %d", api.count()) + } +} + +func TestAIConversionRulesAreVersionScopedAndArchiveNeedsConfirmation(t *testing.T) { + server, api := givenUsageAPI(t, func(w http.ResponseWriter, _ *http.Request, _ []byte) { w.WriteHeader(http.StatusNoContent) }) + _, _, code := runCapturing(t, "--base-url", server.URL, "ai", "conversion-rules", "archive", "team-v2", "rule-1") + if code != exitUsage || api.count() != 0 { + t.Fatalf("exit = %d, requests = %d", code, api.count()) + } + _, stderr, code := runCapturing(t, "--base-url", server.URL, "ai", "conversion-rules", "archive", "team-v2", "rule-1", "--yes") + if code != exitOK { + t.Fatalf("exit %d: %s", code, stderr) + } + if r := api.only(t); r.path != "/licenses/team-v2/conversion-rules/rule-1" || r.method != http.MethodDelete { + t.Fatalf("request = %+v", r) + } +} + +func TestAIReportUsesTransactionIdentityWithoutIdempotencyHeader(t *testing.T) { + server, api := givenUsageAPI(t, func(w http.ResponseWriter, r *http.Request, _ []byte) { + if r.Header.Get("Idempotency-Key") != "" { + t.Errorf("AI report must not carry Idempotency-Key") + } + writeTestJSON(w, http.StatusOK, `{"value":{"type":"number","value":9007199254740993},"ai":{"phase":"commit","credits":"0.000001","transactionId":"call-1"}}`) + }) + for _, phase := range []string{"reserve", "commit"} { + payload := `{"value":{"type":"ai_usage","phase":"` + phase + `","transactionId":"call-1","model":"model-a","requestType":"COMPLETION","usage":{"inputTokens":9007199254740993,"units":"0.000001"}}}` + stdout, stderr, code := runCapturing(t, "--base-url", server.URL, "--output", "json", "ai", "report", "acme", "credits", "--payload", payload) + if code != exitOK { + t.Fatalf("exit %d: %s", code, stderr) + } + expectOutputContains(t, stdout, `"value": 9007199254740993`) + expectOutputContains(t, stdout, `"credits": "0.000001"`) + } + api.mu.Lock() + defer api.mu.Unlock() + for _, r := range api.requests { + if r.method != http.MethodPost || r.path != "/instances/acme/entitlements/credits/usage" || !strings.Contains(string(r.body), `"inputTokens":9007199254740993`) || !strings.Contains(string(r.body), `"transactionId":"call-1"`) { + t.Fatalf("request = %+v", r) + } + } +} + +func TestAIValidationDoesNotSendInvalidInput(t *testing.T) { + server, api := givenUsageAPI(t, func(w http.ResponseWriter, _ *http.Request, _ []byte) { writeTestJSON(w, http.StatusOK, `{}`) }) + cases := [][]string{ + {"models", "list", "--limit", "0"}, {"models", "list", "--limit", "201"}, + {"inferences", "list", "acme", "credits", "--from", "yesterday"}, + {"conversion-rules", "create", "team-v1", "--payload", `{"rates":{"perInputToken":0.000001}}`}, + {"report", "acme", "credits", "--payload", `{"value":{"type":"ai_usage","phase":"reserve"}}`}, + {"report", "acme", "credits", "--payload", `{"behavior":"set","value":{"type":"ai_usage","phase":"commit","transactionId":"call-1"}}`}, + } + for _, args := range cases { + _, stderr, code := runCapturing(t, append([]string{"--base-url", server.URL, "ai"}, args...)...) + if code != exitUsage { + t.Fatalf("%v: exit %d: %s", args, code, stderr) + } + } + if api.count() != 0 { + t.Fatalf("requests = %d", api.count()) + } +} + +func TestAIReadReportsAndErrorExitCodes(t *testing.T) { + server, api := givenUsageAPI(t, func(w http.ResponseWriter, _ *http.Request, _ []byte) { + writeTestJSON(w, http.StatusOK, `{"items":[{"transactionId":"call-1","credits":"999999999999.123456"}],"hasMore":false}`) + }) + stdout, stderr, code := runCapturing(t, "--base-url", server.URL, "--output", "ndjson", "ai", "inferences", "list", "acme", "credits", "--transaction-id", "call-1", "--status", "COMMITTED", "--limit", "500") + if code != exitOK { + t.Fatalf("exit %d: %s", code, stderr) + } + var record map[string]any + if err := json.Unmarshal([]byte(stdout), &record); err != nil || record["credits"] != "999999999999.123456" { + t.Fatalf("stdout = %s", stdout) + } + if r := api.only(t); r.query.Get("transactionId") != "call-1" || r.path != "/instances/acme/entitlements/credits/usage/inferences" { + t.Fatalf("request = %+v", r) + } + for status, expected := range map[int]int{401: exitAuth, 403: exitAuth, 404: exitNotFound, 409: exitRejected, 422: exitRejected, 503: exitUnavailable} { + s, _ := givenUsageAPI(t, func(w http.ResponseWriter, _ *http.Request, _ []byte) { + writeTestJSON(w, status, `{"title":"Refused","detail":"test"}`) + }) + if _, _, code := runCapturing(t, "--base-url", s.URL, "ai", "models", "get", "model-a"); code != expected { + t.Fatalf("status %d: exit %d, want %d", status, code, expected) + } + } +} + +func TestAIReservationsPreserveNumericCountersAndEmptyNDJSON(t *testing.T) { + for _, format := range []string{"json", "yaml"} { + server, api := givenUsageAPI(t, func(w http.ResponseWriter, _ *http.Request, _ []byte) { + writeTestJSON(w, http.StatusOK, `{"items":[],"value":{"type":"number","value":9007199254740993},"credits":"999999999999.123456"}`) + }) + stdout, stderr, code := runCapturing(t, "--base-url", server.URL, "--output", format, "ai", "reservations", "list", "acme", "credits") + if code != exitOK { + t.Fatalf("exit %d: %s", code, stderr) + } + expectOutputContains(t, stdout, "9007199254740993") + expectOutputContains(t, stdout, "999999999999.123456") + if r := api.only(t); r.method != http.MethodGet || r.path != "/instances/acme/entitlements/credits/usage/reservations" { + t.Fatalf("request = %+v", r) + } + } + for _, body := range []string{`{"items":null,"hasMore":false}`, `{"items":[],"hasMore":false}`, `[]`, `null`} { + server, _ := givenUsageAPI(t, func(w http.ResponseWriter, _ *http.Request, _ []byte) { writeTestJSON(w, http.StatusOK, body) }) + stdout, stderr, code := runCapturing(t, "--base-url", server.URL, "--output", "ndjson", "ai", "models", "list") + if code != exitOK || stdout != "" { + t.Fatalf("body %s: exit %d, stdout %q, stderr %s", body, code, stdout, stderr) + } + } +} + +func TestAIReportsAreSentOnceOnFailure(t *testing.T) { + server, api := givenUsageAPI(t, func(w http.ResponseWriter, _ *http.Request, _ []byte) { + writeTestJSON(w, http.StatusServiceUnavailable, `{"title":"Unavailable"}`) + }) + _, _, code := runCapturing(t, "--base-url", server.URL, "ai", "report", "acme", "credits", "--payload", `{"value":{"type":"ai_usage","phase":"commit","transactionId":"call-1"}}`) + if code != exitUnavailable || api.count() != 1 { + t.Fatalf("exit %d, requests %d", code, api.count()) + } +} + +func TestAIConversionRuleListFilters(t *testing.T) { + server, api := givenUsageAPI(t, func(w http.ResponseWriter, _ *http.Request, _ []byte) { writeTestJSON(w, http.StatusOK, `[]`) }) + _, stderr, code := runCapturing(t, "--base-url", server.URL, "ai", "conversion-rules", "list", "team-v2", "--status", "ARCHIVED", "--model", "assistant-v1", "--entitlement", "credits") + if code != exitOK { + t.Fatalf("exit %d: %s", code, stderr) + } + if r := api.only(t); r.path != "/licenses/team-v2/conversion-rules" || r.query.Get("status") != "ARCHIVED" || r.query.Get("entitlementSlug") != "credits" || r.query.Get("modelSlug") != "assistant-v1" { + t.Fatalf("request = %+v", r) + } +} + +func TestAIModelManagementPreservesCostsAndImmutableSlug(t *testing.T) { + server, api := givenUsageAPI(t, func(w http.ResponseWriter, r *http.Request, body []byte) { + if r.Method == http.MethodGet { + writeTestJSON(w, http.StatusOK, currentAIModel) + return + } + writeTestJSON(w, http.StatusOK, string(body)) + }) + for _, args := range [][]string{ + {"create", "--payload", `{"slug":"assistant-v1","name":"Assistant","vendor":"acme","modality":"TEXT","vendorCosts":{"currency":"USD","perInputToken":"0.000000000001"}}`}, + {"update", "assistant-v1", "--payload", `{"slug":"assistant-v1","vendorCosts":{"currency":"USD","perInputToken":"0.000000000001"}}`}, + } { + stdout, stderr, code := runCapturing(t, append([]string{"--base-url", server.URL, "--output", "json", "ai", "models"}, args...)...) + if code != exitOK { + t.Fatalf("exit %d: %s", code, stderr) + } + expectOutputContains(t, stdout, `"perInputToken": "0.000000000001"`) + } + api.mu.Lock() + requests := append([]recordedUsageRequest(nil), api.requests...) + api.mu.Unlock() + if len(requests) != 3 || requests[0].method != http.MethodPost || requests[0].path != "/models" || requests[1].method != http.MethodGet || requests[2].method != http.MethodPut || requests[2].path != "/models/assistant-v1" { + t.Fatalf("requests = %+v", requests) + } + for _, payload := range []string{`{"slug":"renamed-model"}`, `{"vendorCosts":{"perInputToken":0.000000000001}}`} { + if _, _, code := runCapturing(t, "--base-url", server.URL, "ai", "models", "update", "assistant-v1", "--payload", payload); code != exitUsage { + t.Fatalf("payload %s: exit %d", payload, code) + } + } + if api.count() != 3 { + t.Fatalf("invalid update sent: requests = %d", api.count()) + } +} + +func TestAIModelDeleteRequiresConfirmation(t *testing.T) { + server, api := givenUsageAPI(t, func(w http.ResponseWriter, _ *http.Request, _ []byte) { w.WriteHeader(http.StatusNoContent) }) + if _, _, code := runCapturing(t, "--base-url", server.URL, "ai", "models", "delete", "assistant-v1"); code != exitUsage || api.count() != 0 { + t.Fatalf("exit = %d, requests = %d", code, api.count()) + } + stdout, stderr, code := runCapturing(t, "--base-url", server.URL, "ai", "models", "delete", "assistant-v1", "--yes") + if code != exitOK { + t.Fatalf("exit %d: %s", code, stderr) + } + expectOutputContains(t, stdout, "Catalogue model deleted") + if r := api.only(t); r.method != http.MethodDelete || r.path != "/models/assistant-v1" { + t.Fatalf("request = %+v", r) + } +} + +func TestAIModelUpdateMergesStatusAndIndividualPriceDimensions(t *testing.T) { + for _, patch := range []string{`{"status":"DEPRECATED"}`, `{"vendorCosts":{"perInputToken":"0.000000000001"},"metadata":{"added":"new"}}`} { + server, api := givenUsageAPI(t, func(w http.ResponseWriter, r *http.Request, body []byte) { + if r.Method == http.MethodGet { + writeTestJSON(w, http.StatusOK, currentAIModel) + return + } + var model aiDocument + if err := json.Unmarshal(body, &model); err != nil { + t.Errorf("decode body: %v", err) + } + for _, field := range []string{"slug", "name", "vendor", "modality", "status", "description", "externalIds", "metadata", "vendorCosts"} { + if _, present := model[field]; !present { + t.Errorf("PUT omitted %s", field) + } + } + for _, field := range []string{"id", "createdAt", "updatedAt"} { + if _, present := model[field]; present { + t.Errorf("PUT included read-only %s", field) + } + } + if model["slug"] != "assistant-v1" || model["name"] != "Assistant" || model["modality"] != "TEXT" || model["description"] != "keep me" { + t.Errorf("lost current fields: %s", body) + } + costs := model["vendorCosts"].(map[string]any) + metadata := model["metadata"].(map[string]any) + if costs["currency"] != "USD" || costs["perOutputToken"] != "0.000000000003" || metadata["keep"] != "metadata" || metadata["exact"] != json.Number("9007199254740993") { + t.Errorf("lost nested values: %s", body) + } + if strings.Contains(patch, "DEPRECATED") { + if model["status"] != "DEPRECATED" || costs["perInputToken"] != "0.000000000002" { + t.Errorf("status patch = %s", body) + } + } else if costs["perInputToken"] != "0.000000000001" || metadata["added"] != "new" || model["status"] != "ACTIVE" { + t.Errorf("dimension patch = %s", body) + } + writeTestJSON(w, http.StatusOK, string(body)) + }) + _, stderr, code := runCapturing(t, "--base-url", server.URL, "ai", "models", "update", "assistant-v1", "--payload", patch) + if code != exitOK || api.count() != 2 { + t.Fatalf("exit %d, requests %d: %s", code, api.count(), stderr) + } + } +} + +func TestAIConversionRuleUpdatePreservesOmittedPolicyAndReplacesRates(t *testing.T) { + server, api := givenUsageAPI(t, func(w http.ResponseWriter, r *http.Request, body []byte) { + if r.Method == http.MethodGet { + writeTestJSON(w, http.StatusOK, currentAIRule) + return + } + var rule aiDocument + if err := json.Unmarshal(body, &rule); err != nil { + t.Errorf("decode body: %v", err) + } + for key, want := range map[string]any{"entitlementSlug": "credits", "modelSlug": "assistant-v1", "requestType": "COMPLETION", "minimumCreditsPerRequest": "1.000001", "maximumCreditsPerRequest": "10.000001", "chargeOnError": "ALWAYS"} { + if rule[key] != want { + t.Errorf("%s = %v, want %v", key, rule[key], want) + } + } + rates := rule["rates"].(map[string]any) + if len(rates) != 1 || rates["perInputToken"] != "0.000001" { + t.Errorf("rates must be whole-map replacement: %s", body) + } + for _, field := range []string{"id", "licenseSlug", "status", "createdAt", "grantedByLicense"} { + if _, present := rule[field]; present { + t.Errorf("PUT included read-only %s", field) + } + } + writeTestJSON(w, http.StatusOK, string(body)) + }) + _, stderr, code := runCapturing(t, "--base-url", server.URL, "ai", "conversion-rules", "update", "team-v2", "rule-1", "--payload", `{"rates":{"perInputToken":"0.000001"}}`) + if code != exitOK || api.count() != 2 { + t.Fatalf("exit %d, requests %d: %s", code, api.count(), stderr) + } +} + +func TestAIUpdatesRejectImmutableIdentitiesWithoutPUT(t *testing.T) { + cases := []struct{ resource, target, patch, current string }{ + {"models", "assistant-v1", `{"modality":"IMAGE"}`, currentAIModel}, + {"conversion-rules", "team-v2", `{"rates":{},"entitlementSlug":"another"}`, currentAIRule}, + {"conversion-rules", "team-v2", `{"rates":{},"entitlementSlug":null}`, currentAIRule}, + {"conversion-rules", "team-v2", `{"rates":{},"modelSlug":"another"}`, currentAIRule}, + {"conversion-rules", "team-v2", `{"rates":{},"modelSlug":null}`, currentAIRule}, + {"conversion-rules", "team-v2", `{"rates":{},"requestType":"AUDIO"}`, currentAIRule}, + {"conversion-rules", "team-v2", `{"rates":{},"requestType":null}`, currentAIRule}, + } + for _, tc := range cases { + server, api := givenUsageAPI(t, func(w http.ResponseWriter, r *http.Request, _ []byte) { + if r.Method != http.MethodGet { + t.Errorf("immutable update sent %s", r.Method) + } + writeTestJSON(w, http.StatusOK, tc.current) + }) + args := []string{"--base-url", server.URL, "ai", tc.resource, "update", tc.target} + if tc.resource == "conversion-rules" { + args = append(args, "rule-1") + } + _, _, code := runCapturing(t, append(args, "--payload", tc.patch)...) + if code != exitUsage || api.count() != 1 { + t.Fatalf("%s: exit %d, requests %d", tc.patch, code, api.count()) + } + } +} + +func TestAIUpdateStopsOnReadFailureAndRejectsReadOnlyPatch(t *testing.T) { + server, api := givenUsageAPI(t, func(w http.ResponseWriter, r *http.Request, _ []byte) { + if r.Method != http.MethodGet { + t.Errorf("failed read followed by %s", r.Method) + } + writeTestJSON(w, http.StatusNotFound, `{"title":"Not found"}`) + }) + if _, _, code := runCapturing(t, "--base-url", server.URL, "ai", "models", "update", "assistant-v1", "--payload", `{"status":"DEPRECATED"}`); code != exitNotFound || api.count() != 1 { + t.Fatalf("exit %d, requests %d", code, api.count()) + } + if _, _, code := runCapturing(t, "--base-url", server.URL, "ai", "models", "update", "assistant-v1", "--payload", `{"id":"rewrite"}`); code != exitUsage || api.count() != 1 { + t.Fatalf("exit %d, requests %d", code, api.count()) + } +} diff --git a/internal/cmd/root.go b/internal/cmd/root.go index 5f4bfab..f93313d 100644 --- a/internal/cmd/root.go +++ b/internal/cmd/root.go @@ -90,6 +90,7 @@ interrupted by a signal. README.md carries the full table.`, rootCmd.AddCommand(newConfigCommand()) rootCmd.AddCommand(newDoctorCommand()) rootCmd.AddCommand(newBillingCommand()) + rootCmd.AddCommand(newAICommand()) rootCmd.AddCommand(newComponentsCommand()) rootCmd.AddCommand(newCustomersCommand()) rootCmd.AddCommand(newDeploymentZonesCommand()) From b8061524bf38a9a8795feee9cb3c2fe0f1f3435c Mon Sep 17 00:00:00 2001 From: tomflenner Date: Sat, 24 Oct 2026 19:32:11 +0200 Subject: [PATCH 2/3] ci: validate AI pull requests stacked on billing Signed-off-by: tomflenner --- .github/workflows/ci.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 03fd0c3..b60dccf 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -7,7 +7,8 @@ on: push: branches: [ "main" ] pull_request: - branches: [ "main" ] + # AI control-plane PRs stack on the still-open billing branch. + branches: [ "main", "feat/billing" ] jobs: lint: From 326cb2a6c8ba648f4fdfa7731f2e7426e1ed5f66 Mon Sep 17 00:00:00 2001 From: tomflenner Date: Sat, 24 Oct 2026 19:36:15 +0200 Subject: [PATCH 3/3] fix(ci): satisfy AI command lint and formatting Signed-off-by: tomflenner --- internal/cmd/ai.go | 17 +++++++++++------ internal/cmd/ai_test.go | 9 ++++++--- 2 files changed, 17 insertions(+), 9 deletions(-) diff --git a/internal/cmd/ai.go b/internal/cmd/ai.go index cec11b4..d1a3a50 100644 --- a/internal/cmd/ai.go +++ b/internal/cmd/ai.go @@ -46,17 +46,22 @@ func newAICommand() *cobra.Command { filters: map[string]string{"from": "from", "to": "to", "status": "status", "transaction-id": "transactionId", "cursor": "cursor"}, maxLimit: 500, }.command() - return groupCommand("ai", "Manage AI credits and report or inspect accounting (no inference execution)", - aiCall{use: "report ", short: "Record an AI reserve or commit from an authored usage report", - path: aiUsagePath(""), method: http.MethodPost, report: true}.command(), + return groupCommand( + "ai", "Manage AI credits and report or inspect accounting (no inference execution)", + aiCall{ + use: "report ", short: "Record an AI reserve or commit from an authored usage report", + path: aiUsagePath(""), method: http.MethodPost, report: true, + }.command(), groupCommand("models", "Manage the model catalogue (no provider credentials)", models, aiCall{use: "get ", short: "Show a model", path: modelPath}.command(), aiCall{use: "create", short: "Create a catalogue model from JSON or YAML", path: modelPath, method: http.MethodPost, model: true}.command(), aiCall{use: "update ", short: "Update a catalogue model without changing its slug", path: modelPath, method: http.MethodPut, model: true}.command(), aiCall{use: "delete ", short: "Delete a catalogue model", path: modelPath, method: http.MethodDelete, model: true}.command()), groupCommand("conversion-rules", "Manage conversion rules of a specific license version (not a license family)", - aiCall{use: "list ", short: "List a license version's conversion rules", path: rulesPath, - filters: map[string]string{"status": "status", "entitlement": "entitlementSlug", "model": "modelSlug"}}.command(), + aiCall{ + use: "list ", short: "List a license version's conversion rules", path: rulesPath, + filters: map[string]string{"status": "status", "entitlement": "entitlementSlug", "model": "modelSlug"}, + }.command(), aiCall{use: "get ", short: "Show a conversion rule", path: rulesPath}.command(), aiCall{use: "create ", short: "Create a conversion rule from JSON or YAML", path: rulesPath, method: http.MethodPost}.command(), aiCall{use: "update ", short: "Update a conversion rule from JSON or YAML", path: rulesPath, method: http.MethodPut}.command(), @@ -321,7 +326,7 @@ func validateAIRuleDecimals(in map[string]any) error { } } for _, name := range []string{"minimumCreditsPerRequest", "maximumCreditsPerRequest"} { - if value, present := in[name]; present && !(name == "maximumCreditsPerRequest" && value == nil) { + if value, present := in[name]; present && (name != "maximumCreditsPerRequest" || value != nil) { if _, ok := value.(string); !ok { return errUsagef("%s must be a quoted decimal string", name) } diff --git a/internal/cmd/ai_test.go b/internal/cmd/ai_test.go index ae77006..03d253f 100644 --- a/internal/cmd/ai_test.go +++ b/internal/cmd/ai_test.go @@ -7,8 +7,10 @@ import ( "testing" ) -const currentAIModel = `{"id":"model-id","slug":"assistant-v1","name":"Assistant","vendor":"acme","modality":"TEXT","status":"ACTIVE","description":"keep me","externalIds":["provider/model"],"metadata":{"keep":"metadata","exact":9007199254740993},"vendorCosts":{"currency":"USD","perInputToken":"0.000000000002","perOutputToken":"0.000000000003"},"createdAt":"2026-01-01T00:00:00Z","updatedAt":"2026-01-02T00:00:00Z"}` -const currentAIRule = `{"id":"rule-1","licenseSlug":"team-v2","entitlementSlug":"credits","modelSlug":"assistant-v1","requestType":"COMPLETION","rates":{"perInputToken":"0.1","perOutputToken":"0.2"},"minimumCreditsPerRequest":"1.000001","maximumCreditsPerRequest":"10.000001","chargeOnError":"ALWAYS","status":"ACTIVE","grantedByLicense":true,"createdAt":"2026-01-01T00:00:00Z"}` +const ( + currentAIModel = `{"id":"model-id","slug":"assistant-v1","name":"Assistant","vendor":"acme","modality":"TEXT","status":"ACTIVE","description":"keep me","externalIds":["provider/model"],"metadata":{"keep":"metadata","exact":9007199254740993},"vendorCosts":{"currency":"USD","perInputToken":"0.000000000002","perOutputToken":"0.000000000003"},"createdAt":"2026-01-01T00:00:00Z","updatedAt":"2026-01-02T00:00:00Z"}` + currentAIRule = `{"id":"rule-1","licenseSlug":"team-v2","entitlementSlug":"credits","modelSlug":"assistant-v1","requestType":"COMPLETION","rates":{"perInputToken":"0.1","perOutputToken":"0.2"},"minimumCreditsPerRequest":"1.000001","maximumCreditsPerRequest":"10.000001","chargeOnError":"ALWAYS","status":"ACTIVE","grantedByLicense":true,"createdAt":"2026-01-01T00:00:00Z"}` +) func TestAIModelsReadsOnePageWithFiltersAndBasePrefix(t *testing.T) { server, api := givenUsageAPI(t, func(w http.ResponseWriter, r *http.Request, _ []byte) { @@ -96,7 +98,8 @@ func TestAIReportUsesTransactionIdentityWithoutIdempotencyHeader(t *testing.T) { func TestAIValidationDoesNotSendInvalidInput(t *testing.T) { server, api := givenUsageAPI(t, func(w http.ResponseWriter, _ *http.Request, _ []byte) { writeTestJSON(w, http.StatusOK, `{}`) }) cases := [][]string{ - {"models", "list", "--limit", "0"}, {"models", "list", "--limit", "201"}, + {"models", "list", "--limit", "0"}, + {"models", "list", "--limit", "201"}, {"inferences", "list", "acme", "credits", "--from", "yesterday"}, {"conversion-rules", "create", "team-v1", "--payload", `{"rates":{"perInputToken":0.000001}}`}, {"report", "acme", "credits", "--payload", `{"value":{"type":"ai_usage","phase":"reserve"}}`},