diff --git a/CHANGELOG.md b/CHANGELOG.md index 0f12ffe..a7065ef 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,8 @@ Versioning convention (see [README.md](./README.md) for full policy): - `CtrlPushNotifyResult` control message for relay-to-daemon Web Push delivery results - `PushNotifyPayload` tracking fields: `notification_id`, `trace_id`, `created_at_unix_ms`, `attempt` - `PushNotifyResultPayload` for accepted/retryable/permanent Web Push result acknowledgements +- `CostPayload` shared wire type with additive cached-token, pricing-availability, and Codex credit estimate fields +- `RateLimitsUpdatedPayload` additive Codex plan and actual credit snapshot fields ## [v0.1.0] — 2026-04-29 diff --git a/cost.go b/cost.go new file mode 100644 index 0000000..2bc4310 --- /dev/null +++ b/cost.go @@ -0,0 +1,19 @@ +package protocol + +// CostPayload is carried by AgentD "cost" messages. The first six fields are +// the historical wire contract. Later fields are additive and optional so older +// clients and daemons continue to interoperate. +type CostPayload struct { + InputTokens int `json:"input_tokens"` + OutputTokens int `json:"output_tokens"` + TurnCostUSD float64 `json:"turn_cost_usd"` + SessionTotal float64 `json:"session_total_usd"` + BudgetLimit float64 `json:"budget_limit_usd"` // 0 = no limit + BudgetRemain float64 `json:"budget_remain_usd"` // 0 = no limit + CachedInputTokens int `json:"cached_input_tokens,omitempty"` + ReasoningOutputTokens int `json:"reasoning_output_tokens,omitempty"` + PricingModel string `json:"pricing_model,omitempty"` + PricingAvailable bool `json:"pricing_available,omitempty"` + TurnCredits float64 `json:"turn_credits,omitempty"` + SessionTotalCredits float64 `json:"session_total_credits,omitempty"` +} diff --git a/cost_test.go b/cost_test.go new file mode 100644 index 0000000..a013457 --- /dev/null +++ b/cost_test.go @@ -0,0 +1,55 @@ +package protocol_test + +import ( + "encoding/json" + "testing" + + protocol "github.com/hishamkaram/agentd-protocol" +) + +func TestCostPayloadBackwardCompatibleRoundTrip(t *testing.T) { + t.Parallel() + + raw := []byte(`{"input_tokens":100,"output_tokens":50,"turn_cost_usd":0.01,"session_total_usd":0.02,"budget_limit_usd":5,"budget_remain_usd":4.98}`) + var got protocol.CostPayload + if err := json.Unmarshal(raw, &got); err != nil { + t.Fatalf("unmarshal old payload: %v", err) + } + if got.InputTokens != 100 || got.OutputTokens != 50 || got.TurnCostUSD != 0.01 || + got.SessionTotal != 0.02 || got.BudgetLimit != 5 || got.BudgetRemain != 4.98 { + t.Fatalf("old fields mismatch: %+v", got) + } + if got.CachedInputTokens != 0 || got.PricingAvailable || got.TurnCredits != 0 { + t.Fatalf("new fields should default to zero values: %+v", got) + } +} + +func TestCostPayloadAdditiveFieldsRoundTrip(t *testing.T) { + t.Parallel() + + in := protocol.CostPayload{ + InputTokens: 1000, + CachedInputTokens: 250, + OutputTokens: 100, + ReasoningOutputTokens: 40, + TurnCostUSD: 0.1234, + SessionTotal: 0.5678, + BudgetLimit: 5, + BudgetRemain: 4.4322, + PricingModel: "gpt-5.5", + PricingAvailable: true, + TurnCredits: 1.25, + SessionTotalCredits: 2.5, + } + raw, err := json.Marshal(in) + if err != nil { + t.Fatalf("marshal: %v", err) + } + var got protocol.CostPayload + if err := json.Unmarshal(raw, &got); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if got != in { + t.Fatalf("round-trip mismatch\nwant: %+v\n got: %+v\n raw: %s", in, got, raw) + } +} diff --git a/ratelimits.go b/ratelimits.go index bdb6e16..af9970a 100644 --- a/ratelimits.go +++ b/ratelimits.go @@ -45,9 +45,13 @@ type RateLimitBucket struct { // MsgOutput text "rate limits updated" (preserves feature 185 behavior) and // logs `codex_rate_limits_parse_failed` at warn level. type RateLimitsUpdatedPayload struct { - PrimaryWindow *RateLimitBucket `json:"primary_window,omitempty"` - SecondaryWindow *RateLimitBucket `json:"secondary_window,omitempty"` - ByLimitID map[string]*RateLimitBucket `json:"by_limit_id,omitempty"` - SessionID string `json:"session_id"` - ReceivedAtMillis int64 `json:"received_at_ms"` + PrimaryWindow *RateLimitBucket `json:"primary_window,omitempty"` + SecondaryWindow *RateLimitBucket `json:"secondary_window,omitempty"` + ByLimitID map[string]*RateLimitBucket `json:"by_limit_id,omitempty"` + SessionID string `json:"session_id"` + ReceivedAtMillis int64 `json:"received_at_ms"` + PlanType string `json:"plan_type,omitempty"` + CreditsBalance string `json:"credits_balance,omitempty"` + CreditsHasCredits *bool `json:"credits_has_credits,omitempty"` + CreditsUnlimited *bool `json:"credits_unlimited,omitempty"` } diff --git a/ratelimits_test.go b/ratelimits_test.go index 00abde6..0ae1e90 100644 --- a/ratelimits_test.go +++ b/ratelimits_test.go @@ -80,11 +80,15 @@ func TestRateLimitsUpdatedPayloadFields(t *testing.T) { jsonTag string } cases := map[string]want{ - "PrimaryWindow": {"*", "primary_window,omitempty"}, - "SecondaryWindow": {"*", "secondary_window,omitempty"}, - "ByLimitID": {"map[string]", "by_limit_id,omitempty"}, - "SessionID": {"string", "session_id"}, - "ReceivedAtMillis": {"int64", "received_at_ms"}, + "PrimaryWindow": {"*", "primary_window,omitempty"}, + "SecondaryWindow": {"*", "secondary_window,omitempty"}, + "ByLimitID": {"map[string]", "by_limit_id,omitempty"}, + "SessionID": {"string", "session_id"}, + "ReceivedAtMillis": {"int64", "received_at_ms"}, + "PlanType": {"string", "plan_type,omitempty"}, + "CreditsBalance": {"string", "credits_balance,omitempty"}, + "CreditsHasCredits": {"*", "credits_has_credits,omitempty"}, + "CreditsUnlimited": {"*", "credits_unlimited,omitempty"}, } if got := typ.NumField(); got != len(cases) { @@ -155,8 +159,12 @@ func TestRateLimitsUpdatedPayloadRoundTrip(t *testing.T) { "rpm-5h": {UsedPercent: 42.5, LabelID: "rpm-5h"}, "weekly": {UsedPercent: 12.0, LabelID: "weekly"}, }, - SessionID: "sess-abc", - ReceivedAtMillis: 1713600000000, + SessionID: "sess-abc", + ReceivedAtMillis: 1713600000000, + PlanType: "plus", + CreditsBalance: "1200", + CreditsHasCredits: boolPtr(true), + CreditsUnlimited: boolPtr(false), }, }, { @@ -245,6 +253,10 @@ func TestRateLimitsUpdatedPayloadJSONKeys(t *testing.T) { } } +func boolPtr(v bool) *bool { + return &v +} + // TestRateLimitsUpdatedPayloadMalformedJSON verifies malformed input returns a // Go unmarshal error rather than panicking. Per data-model.md the daemon-side // parser falls back to the MsgOutput text path on error. @@ -252,7 +264,7 @@ func TestRateLimitsUpdatedPayloadMalformedJSON(t *testing.T) { t.Parallel() badInputs := []string{ - `{`, // truncated + `{`, // truncated `{"primary_window": "not-an-object"}`, // type mismatch `{"received_at_ms": "not-a-number"}`, `null-garbage`,