ask-guests 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 47d3aa99bde7a4fba72632740871294a132d06a492d679c78d5487daa5857506
4
- data.tar.gz: 6f937c5d724bd72b036faed46a1490129b37ee59380da63d8ba781b2f243b9db
3
+ metadata.gz: 4714198941d2dca3569099d2c4f4091bd06adcfa00b74ecbea2d06b1cc9c6977
4
+ data.tar.gz: 5bcfc52ebd6b29d135bd923b3392221c9342ba1057d131349c4f7f0b0d2025e8
5
5
  SHA512:
6
- metadata.gz: 7691132899688d326b7014fd010a7887a107958e717ea1da66b7640fe2fb4b4ace845d0f6c5db23f483904ff13e00d19fe4510b69539a2b1ad91a0cf18c9a429
7
- data.tar.gz: 7667b6be0f7ab4634020198f0e30566ea1b3338b79747017c3c7460e8910ca664bbf7222c41902f013d0a161e84481df09bf35b1e69c610ca34756e7c701b72a
6
+ metadata.gz: 4c921fa2484da89d6aef5f00bdf4213bef01bab9df1c25dbc167f61e32672a3b68da54f9f58a47bf7c80b2ad6dcb583dfadb0a7b4a5b187507f209d65c6bdf1f
7
+ data.tar.gz: 5b8755b9083f995df2adbc6a0679336f343e20d27cb38713f6fab44c2ddc2ebe510878bb08829d8e54fd6cedddab8c88cd1b30377b17294dad23b184d0837740
data/CHANGELOG.md CHANGED
@@ -1,5 +1,22 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.2.0] — 2026-09-16
4
+
5
+ ### Changed
6
+
7
+ - **Budgets are now named categories instead of policy classes.** Replace
8
+ `config.policies = [Budget::TurnLimit.new(per_day: 40)]` with
9
+ `config.categories = {turns: {limit: 40, per: :day}}`. Each category is a
10
+ named limit with a period (`:day`, `:week`, `:month`) and optionally its
11
+ own usage key (`parses: {limit: 20, per: :day, usage_key: :uploads}`).
12
+ Any usage key is meterable — turns, tokens, uploads, messages, or your
13
+ own.
14
+ - **New budget API:** `allowed?(subject, *categories)`, `remaining(subject,
15
+ :turns)`, `exceeded(subject)` (categories out of budget), `record!` with
16
+ keyword usage (`record!(session, turns: 1, tokens: 850)`).
17
+ - `Budget::TurnLimit`, `Budget::TokenQuota`, `Budget::Unlimited`, and
18
+ `Budget::Policy` are gone; an empty category list means unlimited.
19
+
3
20
  ## [0.1.0] — 2026-09-15
4
21
 
5
22
  ### Added
data/README.md CHANGED
@@ -11,7 +11,7 @@ Four concepts, one gem:
11
11
  |---|---|
12
12
  | **Identity** | A random session id behind an HMAC-signed token. No fake `User` rows, nothing to enumerate without the secret. |
13
13
  | **Ownership** | Hosts register claimable handlers; records owned by a guest carry its session id. |
14
- | **Metering** | Daily turn/token budgets (composable policies) with automatic rollover — bound anonymous AI spend. |
14
+ | **Metering** | Named budget categories (`turns: 40/day`, `tokens: 10k/day`, `uploads: 5/day`, …) with automatic period rollover — bound anonymous AI spend. |
15
15
  | **Claim** | On sign-up/sign-in, transfer all guest records in one transaction, exactly once. |
16
16
 
17
17
  Plus retention: unclaimed sessions are swept after a configurable window.
@@ -46,7 +46,10 @@ the model and controller wiring.
46
46
  Ask::Guests.configure do |config|
47
47
  config.secret = ENV.fetch("GUEST_TOKEN_SECRET")
48
48
  config.store = Ask::Guests::Stores::Memory.new
49
- config.policies = [Ask::Guests::Budget::TurnLimit.new(per_day: 40)]
49
+ config.categories = {
50
+ turns: {limit: 40, per: :day},
51
+ tokens: {limit: 10_000, per: :day}
52
+ }
50
53
  config.retention = 14 * 24 * 60 * 60 # seconds
51
54
  end
52
55
 
@@ -77,14 +80,26 @@ response = agent.run(prompt)
77
80
  budget.record!(guest_session, turns: 1, tokens: response.input_tokens + response.output_tokens)
78
81
  ```
79
82
 
80
- Policies compose — a guest is allowed only while every policy has budget
81
- left, and counters roll over daily with no scheduled work:
83
+ Categories compose — a guest is allowed only while every category has
84
+ budget left in its period, and counters roll over with no scheduled work.
85
+ Periods are `:day`, `:week`, or `:month`; any usage key can be metered,
86
+ including your own (uploads, parses, messages):
82
87
 
83
88
  ```ruby
84
- config.policies = [
85
- Ask::Guests::Budget::TurnLimit.new(per_day: 40),
86
- Ask::Guests::Budget::TokenQuota.new(per_day: 10_000)
87
- ]
89
+ config.categories = {
90
+ turns: {limit: 40, per: :day},
91
+ tokens: {limit: 10_000, per: :day},
92
+ uploads: {limit: 5, per: :day},
93
+ messages: {limit: 100, per: :month},
94
+ parses: {limit: 20, per: :day, usage_key: :uploads} # meter one key as another category
95
+ }
96
+
97
+ budget = Ask::Guests.budget
98
+ budget.allowed?(guest) # all categories
99
+ budget.allowed?(guest, :turns) # one category
100
+ budget.remaining(guest, :turns) # => 12
101
+ budget.exceeded(guest) # => [:tokens]
102
+ budget.record!(guest, turns: 1, tokens: 850)
88
103
  ```
89
104
 
90
105
  ### Claiming on sign-up
@@ -121,7 +136,7 @@ end
121
136
  Ask::Guests.configure do |config|
122
137
  config.secret = Rails.application.secret_key_base
123
138
  config.store = Ask::Guests::ActiveRecord::SessionStore.new(model: GuestSession)
124
- config.policies = [Ask::Guests::Budget::TurnLimit.new(per_day: 40)]
139
+ config.categories = {turns: {limit: 40, per: :day}}
125
140
  end
126
141
  ```
127
142
 
@@ -0,0 +1,87 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ask
4
+ module Guests
5
+ class Budget
6
+ # A named budget line: a limit on one usage category per period.
7
+ #
8
+ # Category.new(:turns, limit: 40, per: :day)
9
+ # Category.new(:tokens, limit: 10_000, per: :day)
10
+ # Category.new(:uploads, limit: 5, per: :day)
11
+ # Category.new(:messages, limit: 100, per: :month)
12
+ #
13
+ # Usage is recorded by keyword and consumed by the category with the
14
+ # same name:
15
+ #
16
+ # budget.record!(session, turns: 1, tokens: 850, uploads: 1)
17
+ #
18
+ # A category may meter a different usage key with +usage_key+ (for
19
+ # example, counting "parses" while the call site reports uploads).
20
+ #
21
+ # Counters live on the subject and store a period bucket alongside
22
+ # the count ("day" resets at midnight UTC, "week" on ISO weeks,
23
+ # "month" on calendar months), so rollover is automatic and needs no
24
+ # scheduled work.
25
+ class Category
26
+ PERIODS = %i[day week month].freeze
27
+
28
+ attr_reader :name, :limit, :period, :usage_key
29
+
30
+ def initialize(name, limit:, per: :day, usage_key: nil)
31
+ @name = name.to_sym
32
+ @limit = limit.to_i
33
+ @period = per.to_sym
34
+ @usage_key = (usage_key || name).to_sym
35
+
36
+ unless PERIODS.include?(@period)
37
+ raise ArgumentError, "unknown period #{per.inspect} (expected one of #{PERIODS.inspect})"
38
+ end
39
+ end
40
+
41
+ # @return [Integer] how much this usage costs the category
42
+ def amount_for(usage)
43
+ usage.fetch(@usage_key, 0).to_i
44
+ end
45
+
46
+ # @return [Integer] remaining budget for the current period
47
+ def remaining(subject, time)
48
+ value = subject.counter_value(counter_key, bucket(time))
49
+ [@limit - value, 0].max
50
+ end
51
+
52
+ def exceeded?(subject, time)
53
+ remaining(subject, time).zero?
54
+ end
55
+
56
+ # Records usage on the subject (without persisting — the budget
57
+ # persists through the store).
58
+ def record!(subject, usage, time)
59
+ amount = amount_for(usage)
60
+ return subject if amount.zero?
61
+
62
+ count = subject.counter_value(counter_key, bucket(time)) + amount
63
+ subject.set_counter!(counter_key, count, bucket(time))
64
+ end
65
+
66
+ # Counter name on the subject.
67
+ def counter_key
68
+ @name.to_s
69
+ end
70
+
71
+ # Period bucket key for +time+; a different bucket reads as zero.
72
+ def bucket(time)
73
+ utc = time.utc
74
+ case @period
75
+ when :day then utc.strftime("%Y-%m-%d")
76
+ when :week then utc.strftime("%G-W%V")
77
+ when :month then utc.strftime("%Y-%m")
78
+ end
79
+ end
80
+
81
+ def to_h
82
+ {name: name, limit: limit, period: period, usage_key: usage_key}
83
+ end
84
+ end
85
+ end
86
+ end
87
+ end
@@ -2,66 +2,104 @@
2
2
 
3
3
  module Ask
4
4
  module Guests
5
- # Meters guest usage against one or more policies and records usage
6
- # afterwards. Policies are checked collectively: a guest is allowed only
7
- # while every policy has budget remaining.
5
+ # Meters usage against named category budgets and records usage
6
+ # afterwards. Categories are checked collectively: a subject is allowed
7
+ # only while every category has budget remaining in its period.
8
8
  #
9
9
  # budget = Ask::Guests.budget
10
- # budget.allowed?(session) # => true/false
11
- # budget.remaining(session) # => Integer (minimum across policies)
10
+ # budget.allowed?(session) # => true/false
11
+ # budget.allowed?(session, :turns) # => one category
12
+ # budget.remaining(session, :turns) # => 12
13
+ # budget.exceeded(session) # => [:tokens]
12
14
  # budget.record!(session, turns: 1, tokens: 850)
13
15
  #
14
- # Counters live on the session and roll over daily, so "per day" limits
16
+ # Counters live on the subject and roll over per period, so limits
15
17
  # reset without any scheduled work.
16
18
  class Budget
17
- attr_reader :policies, :store
19
+ attr_reader :categories, :store
18
20
 
19
- def initialize(policies: nil, store: Guests.store, clock: Guests.configuration.clock)
20
- @policies = normalize(policies)
21
+ # @param categories [Array<Category>, Hash, nil] Category objects or
22
+ # a hash of +name => {limit:, per:, usage_key:}+. Empty/nil means
23
+ # unlimited.
24
+ def initialize(categories: nil, store: Guests.store, clock: Guests.configuration.clock)
25
+ @categories = normalize(categories)
21
26
  @store = store
22
27
  @clock = clock
23
28
  end
24
29
 
25
- # @return [Boolean] true while every policy has budget left
26
- def allowed?(session)
27
- return true if session.nil?
30
+ # @param names [Array<Symbol>] restrict the check to these categories
31
+ # @return [Boolean] true while every checked category has budget left
32
+ def allowed?(subject, *names)
33
+ return true if subject.nil?
28
34
 
29
- policies.all? { |policy| policy.remaining(session, today) > 0 }
35
+ selected(names).all? { |category| category.remaining(subject, now) > 0 }
30
36
  end
31
37
 
32
- # Minimum remaining budget across policies (Float::INFINITY when no
33
- # policy limits usage).
34
- def remaining(session)
35
- return Float::INFINITY if session.nil?
38
+ # Remaining budget for one category, or the minimum across all
39
+ # categories (Float::INFINITY when unlimited).
40
+ def remaining(subject, name = nil)
41
+ return Float::INFINITY if subject.nil?
36
42
 
37
- policies.map { |policy| policy.remaining(session, today) }.min
43
+ if name
44
+ category(name).remaining(subject, now)
45
+ else
46
+ categories.map { |category| category.remaining(subject, now) }.min || Float::INFINITY
47
+ end
48
+ end
49
+
50
+ # @return [Array<Symbol>] categories with no budget left this period
51
+ def exceeded(subject)
52
+ return [] if subject.nil?
53
+
54
+ categories.select { |category| category.exceeded?(subject, now) }.map(&:name)
38
55
  end
39
56
 
40
- # Records usage, persists the session through the store, and returns it.
41
- def record!(session, turns: 0, tokens: 0)
42
- return session if session.nil?
57
+ # Records usage, persists the subject through the store, and returns
58
+ # it. Usage is keyed by category name (or the category's usage_key):
59
+ #
60
+ # budget.record!(session, turns: 1, tokens: 850, uploads: 2)
61
+ def record!(subject, **usage)
62
+ return subject if subject.nil?
43
63
 
44
- policies.each { |policy| policy.record!(session, turns: turns, tokens: tokens, today: today) }
45
- store.update(session)
46
- session
64
+ categories.each { |category| category.record!(subject, usage, now) }
65
+ store.update(subject)
66
+ subject
47
67
  end
48
68
 
49
69
  def unlimited?
50
- policies.all? { |policy| policy.remaining(Session.new(id: "probe"), today) == Float::INFINITY }
70
+ categories.empty?
71
+ end
72
+
73
+ # @return [Category]
74
+ def category(name)
75
+ categories.find { |category| category.name == name.to_sym } ||
76
+ raise(ArgumentError, "unknown budget category #{name.inspect}")
51
77
  end
52
78
 
53
79
  private
54
80
 
55
- def normalize(policies)
56
- case policies
57
- when nil then [Budget::Unlimited.new]
58
- when Array then policies
59
- else [policies]
60
- end
81
+ def selected(names)
82
+ return categories if names.nil? || names.empty?
83
+
84
+ names.map { |name| category(name) }
85
+ end
86
+
87
+ def now
88
+ @clock.call
89
+ end
90
+
91
+ def normalize(categories)
92
+ case categories
93
+ when nil then []
94
+ when Hash then categories.flat_map { |name, options| build(name, options) }
95
+ when Array then categories.flat_map { |entry| entry.is_a?(Hash) ? normalize(entry) : [entry] }
96
+ else [categories]
97
+ end.map { |entry| entry.is_a?(Category) ? entry : build(entry, {}) }
61
98
  end
62
99
 
63
- def today
64
- @clock.call.utc.strftime("%Y-%m-%d")
100
+ def build(name, options)
101
+ options = options.to_h.transform_keys(&:to_sym)
102
+ [Category.new(name, **options)]
65
103
  end
66
104
  end
67
105
  end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Ask
4
4
  module Guests
5
- VERSION = "0.1.0"
5
+ VERSION = "0.2.0"
6
6
  end
7
7
  end
data/lib/ask/guests.rb CHANGED
@@ -7,10 +7,7 @@ require_relative "guests/session"
7
7
  require_relative "guests/stores/base"
8
8
  require_relative "guests/stores/memory"
9
9
  require_relative "guests/budget"
10
- require_relative "guests/budget/policy"
11
- require_relative "guests/budget/unlimited"
12
- require_relative "guests/budget/turn_limit"
13
- require_relative "guests/budget/token_quota"
10
+ require_relative "guests/budget/category"
14
11
  require_relative "guests/claim"
15
12
  require_relative "guests/sweep"
16
13
 
@@ -79,7 +76,7 @@ module Ask
79
76
  # so configuration changes (and test resets) always take effect.
80
77
  def budget
81
78
  Budget.new(
82
- policies: configuration.policies,
79
+ categories: configuration.categories,
83
80
  store: configuration.store,
84
81
  clock: configuration.clock
85
82
  )
@@ -121,8 +118,8 @@ module Ask
121
118
  attr_accessor :secret
122
119
  # Persistence adapter (see Stores::Base). Defaults to in-memory.
123
120
  attr_accessor :store
124
- # Usage policies (see Budget::TurnLimit, Budget::TokenQuota).
125
- attr_accessor :policies
121
+ # Budget categories (see Budget::Category): named limits per period.
122
+ attr_accessor :categories
126
123
  # Unclaimed sessions older than this many seconds are swept.
127
124
  attr_accessor :retention
128
125
  # Injectable clock returning a Time (used for daily counter rollover
@@ -141,7 +138,7 @@ module Ask
141
138
  def initialize
142
139
  @secret = nil
143
140
  @store = Stores::Memory.new
144
- @policies = [Budget::Unlimited.new]
141
+ @categories = []
145
142
  @retention = 14 * 24 * 60 * 60
146
143
  @clock = -> { Time.now.utc }
147
144
  @cookie_name = "guest_session_token"
@@ -19,12 +19,15 @@ Ask::Guests.configure do |config|
19
19
  # end
20
20
  config.store = Ask::Guests::ActiveRecord::SessionStore.new(model: GuestSession)
21
21
 
22
- # Usage caps for anonymous visitors. Signing up lifts them because a
23
- # signed-in request never touches the guest budget.
24
- config.policies = [
25
- Ask::Guests::Budget::TurnLimit.new(per_day: 40)
26
- # Ask::Guests::Budget::TokenQuota.new(per_day: 10_000)
27
- ]
22
+ # Usage caps for anonymous visitors, one entry per category. Signing up
23
+ # lifts them because a signed-in request never touches the guest budget.
24
+ # Periods are :day, :week, or :month; counters roll over automatically.
25
+ config.categories = {
26
+ turns: {limit: 40, per: :day}
27
+ # tokens: {limit: 10_000, per: :day},
28
+ # uploads: {limit: 5, per: :day},
29
+ # messages: {limit: 100, per: :month}
30
+ }
28
31
 
29
32
  # Unclaimed sessions inactive for this long are swept (seconds).
30
33
  config.retention = 14 * 24 * 60 * 60
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: ask-guests
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Kaka Ruto
@@ -114,10 +114,7 @@ files:
114
114
  - lib/ask/guests/active_record/session_base.rb
115
115
  - lib/ask/guests/active_record/session_store.rb
116
116
  - lib/ask/guests/budget.rb
117
- - lib/ask/guests/budget/policy.rb
118
- - lib/ask/guests/budget/token_quota.rb
119
- - lib/ask/guests/budget/turn_limit.rb
120
- - lib/ask/guests/budget/unlimited.rb
117
+ - lib/ask/guests/budget/category.rb
121
118
  - lib/ask/guests/claim.rb
122
119
  - lib/ask/guests/errors.rb
123
120
  - lib/ask/guests/rails/claim_on_authentication.rb
@@ -1,42 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Ask
4
- module Guests
5
- class Budget
6
- # Base class for usage policies. A policy owns one counter key (for
7
- # example "turns" or "tokens") and a daily limit for it.
8
- #
9
- # Subclasses implement +counter_key+, +limit+, and +amount_for+.
10
- class Policy
11
- # @return [String] counter name on the session
12
- def counter_key
13
- raise NotImplementedError
14
- end
15
-
16
- # @return [Integer, Float::INFINITY] daily limit
17
- def limit
18
- raise NotImplementedError
19
- end
20
-
21
- # @return [Integer] how much this usage costs in counter units
22
- def amount_for(turns:, tokens:)
23
- raise NotImplementedError
24
- end
25
-
26
- # @return [Integer, Float] remaining budget for today
27
- def remaining(session, today)
28
- value = session.counter_value(counter_key, today)
29
- [limit - value, 0].max
30
- end
31
-
32
- def record!(session, turns:, tokens:, today:)
33
- amount = amount_for(turns: turns, tokens: tokens)
34
- return session if amount.to_i.zero?
35
-
36
- count = session.counter_value(counter_key, today) + amount.to_i
37
- session.set_counter!(counter_key, count, today)
38
- end
39
- end
40
- end
41
- end
42
- end
@@ -1,24 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Ask
4
- module Guests
5
- class Budget
6
- # Limits tokens per day, for apps that meter actual consumption
7
- # (input + output tokens) instead of turns.
8
- #
9
- # Budget::TokenQuota.new(per_day: 10_000)
10
- class TokenQuota < Policy
11
- attr_reader :per_day
12
-
13
- def initialize(per_day:)
14
- super()
15
- @per_day = per_day.to_i
16
- end
17
-
18
- def counter_key = "tokens"
19
- def limit = per_day
20
- def amount_for(turns:, tokens:) = tokens
21
- end
22
- end
23
- end
24
- end
@@ -1,24 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Ask
4
- module Guests
5
- class Budget
6
- # Limits agent turns per day. One "turn" is one agent run — the unit
7
- # Kawibot meters.
8
- #
9
- # Budget::TurnLimit.new(per_day: 40)
10
- class TurnLimit < Policy
11
- attr_reader :per_day
12
-
13
- def initialize(per_day:)
14
- super()
15
- @per_day = per_day.to_i
16
- end
17
-
18
- def counter_key = "turns"
19
- def limit = per_day
20
- def amount_for(turns:, tokens:) = turns
21
- end
22
- end
23
- end
24
- end
@@ -1,16 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Ask
4
- module Guests
5
- class Budget
6
- # No limits — the default policy. Useful for development and for apps
7
- # that meter elsewhere.
8
- class Unlimited < Policy
9
- def counter_key = "unlimited"
10
- def limit = Float::INFINITY
11
- def amount_for(turns:, tokens:) = 0
12
- def remaining(session, today) = Float::INFINITY
13
- end
14
- end
15
- end
16
- end