ask-guests 0.1.0 → 0.3.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 +4 -4
- data/CHANGELOG.md +42 -0
- data/README.md +61 -13
- data/db/migrate/20260917120000_create_ask_guest_sessions.rb +30 -0
- data/lib/ask/guests/active_record/session_base.rb +9 -1
- data/lib/ask/guests/active_record/session_store.rb +14 -1
- data/lib/ask/guests/budget/category.rb +87 -0
- data/lib/ask/guests/budget.rb +71 -33
- data/lib/ask/guests/errors.rb +19 -0
- data/lib/ask/guests/version.rb +1 -1
- data/lib/ask/guests.rb +5 -8
- data/lib/generators/ask/guests/install/install_generator.rb +81 -22
- data/lib/generators/ask/guests/install/templates/guest_session.rb +16 -0
- data/lib/generators/ask/guests/install/templates/initializer.rb +36 -29
- metadata +18 -6
- data/lib/ask/guests/budget/policy.rb +0 -42
- data/lib/ask/guests/budget/token_quota.rb +0 -24
- data/lib/ask/guests/budget/turn_limit.rb +0 -24
- data/lib/ask/guests/budget/unlimited.rb +0 -16
- data/lib/generators/ask/guests/install/templates/migration.rb +0 -16
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 8bec805817f573f8d9fcbc2b5e7bc7c0e6622aed391b4af6d61f02c3544f1c10
|
|
4
|
+
data.tar.gz: 381594c955f847741546177fc5bb03ead0e71ed6baa62f5adc98277e736fa223
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: f97bf4428e04bfcb3fb018b76663b825ed46357419ec43ecd5bc2b3c5fe18463fcc8d9f42ecb6d26be59a43404f3d5fa04b1ee2c715983fa971b09c08318d1c5
|
|
7
|
+
data.tar.gz: cdc1be08b4f0ef9c13e93625c16be13fb35198fbd0ed2b9d222fc76cde0989b1fc456804aee2bf8a5cc7016ad3cf4c5304e74833638ed9f898b5a54704aa9c6b
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,47 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.3.0] — 2026-09-17
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
|
|
7
|
+
- **The table ships with the gem.** `db/migrate/create_ask_guest_sessions.rb` is
|
|
8
|
+
now the one definition of `ask_guest_sessions`, and the install generator copies
|
|
9
|
+
it into the host (`migration_template`, so it lands with a fresh timestamp
|
|
10
|
+
and is skipped if the host already has one). Re-running the installer after
|
|
11
|
+
an upgrade installs any migration a later version adds.
|
|
12
|
+
- **The installer writes the whole app-side wiring:** the migration, the
|
|
13
|
+
`GuestSession` model, and `config/initializers/ask_guests.rb` — then prints
|
|
14
|
+
the controller, claim, and sweep steps. Nothing is left for the host to
|
|
15
|
+
transcribe from a README.
|
|
16
|
+
- **`Ask::Guests::MissingTable`** — the ActiveRecord store checks for its table
|
|
17
|
+
on the first read or write (`#verify_schema!`) and raises this, naming the
|
|
18
|
+
file to copy, instead of surfacing a database error about a relation.
|
|
19
|
+
- The README documents the non-Rails path (plain ActiveRecord/Sinatra) and why
|
|
20
|
+
the host owns the schema rather than the gem creating it at boot.
|
|
21
|
+
|
|
22
|
+
### Changed
|
|
23
|
+
|
|
24
|
+
- `Ask::Guests::ActiveRecord::SessionStore` raises on a missing table for
|
|
25
|
+
`#create`, `#find`, and `#sweep_stale`; `#verify_schema!` is public if a host
|
|
26
|
+
wants to check at boot.
|
|
27
|
+
|
|
28
|
+
## [0.2.0] — 2026-09-16
|
|
29
|
+
|
|
30
|
+
### Changed
|
|
31
|
+
|
|
32
|
+
- **Budgets are now named categories instead of policy classes.** Replace
|
|
33
|
+
`config.policies = [Budget::TurnLimit.new(per_day: 40)]` with
|
|
34
|
+
`config.categories = {turns: {limit: 40, per: :day}}`. Each category is a
|
|
35
|
+
named limit with a period (`:day`, `:week`, `:month`) and optionally its
|
|
36
|
+
own usage key (`parses: {limit: 20, per: :day, usage_key: :uploads}`).
|
|
37
|
+
Any usage key is meterable — turns, tokens, uploads, messages, or your
|
|
38
|
+
own.
|
|
39
|
+
- **New budget API:** `allowed?(subject, *categories)`, `remaining(subject,
|
|
40
|
+
:turns)`, `exceeded(subject)` (categories out of budget), `record!` with
|
|
41
|
+
keyword usage (`record!(session, turns: 1, tokens: 850)`).
|
|
42
|
+
- `Budget::TurnLimit`, `Budget::TokenQuota`, `Budget::Unlimited`, and
|
|
43
|
+
`Budget::Policy` are gone; an empty category list means unlimited.
|
|
44
|
+
|
|
3
45
|
## [0.1.0] — 2026-09-15
|
|
4
46
|
|
|
5
47
|
### 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** |
|
|
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.
|
|
@@ -31,14 +31,47 @@ require "ask-guests/rails" # controller concern, auth hook, sweep job
|
|
|
31
31
|
gem "ask-guests"
|
|
32
32
|
```
|
|
33
33
|
|
|
34
|
-
|
|
34
|
+
### Rails
|
|
35
35
|
|
|
36
36
|
```bash
|
|
37
37
|
bin/rails generate ask:guests:install
|
|
38
|
+
bin/rails db:migrate
|
|
38
39
|
```
|
|
39
40
|
|
|
40
|
-
|
|
41
|
-
|
|
41
|
+
The installer copies the `ask_guest_sessions` migration out of the gem, writes
|
|
42
|
+
`app/models/guest_session.rb`, creates `config/initializers/ask_guests.rb`, and
|
|
43
|
+
prints the controller wiring. Then register what claiming transfers (see
|
|
44
|
+
[Claim](#claim)) and schedule the sweep (see [Retention](#retention)).
|
|
45
|
+
|
|
46
|
+
The table ships with the gem (`db/migrate/create_ask_guest_sessions.rb`) rather
|
|
47
|
+
than being created by it. Your app owns its schema: migrations run at deploy
|
|
48
|
+
time, under review, against whichever database you point them at — a gem that
|
|
49
|
+
creates tables at boot would need DDL rights at runtime, race other servers,
|
|
50
|
+
and have to guess which database is yours. Re-running the installer after
|
|
51
|
+
upgrading installs any migration a later version added, and skips the ones you
|
|
52
|
+
already have.
|
|
53
|
+
|
|
54
|
+
### Anywhere else (plain ActiveRecord, Sinatra, a script)
|
|
55
|
+
|
|
56
|
+
The core needs nothing; the ActiveRecord adapter needs the table:
|
|
57
|
+
|
|
58
|
+
```ruby
|
|
59
|
+
require "ask-guests/active_record"
|
|
60
|
+
|
|
61
|
+
class GuestSession < Ask::Guests::ActiveRecord::SessionBase
|
|
62
|
+
self.table_name = "ask_guest_sessions"
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
Ask::Guests.configure do |config|
|
|
66
|
+
config.secret = ENV.fetch("GUEST_TOKEN_SECRET")
|
|
67
|
+
config.store = Ask::Guests::ActiveRecord::SessionStore.new(model: GuestSession)
|
|
68
|
+
end
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
Copy `db/migrate/create_ask_guest_sessions.rb` from the gem into your migrations
|
|
72
|
+
and run them however you migrate. If the table is missing, the store raises
|
|
73
|
+
`Ask::Guests::MissingTable` at the first read or write — with the file to copy
|
|
74
|
+
in the message — rather than letting the database complain about a relation.
|
|
42
75
|
|
|
43
76
|
## Core usage
|
|
44
77
|
|
|
@@ -46,7 +79,10 @@ the model and controller wiring.
|
|
|
46
79
|
Ask::Guests.configure do |config|
|
|
47
80
|
config.secret = ENV.fetch("GUEST_TOKEN_SECRET")
|
|
48
81
|
config.store = Ask::Guests::Stores::Memory.new
|
|
49
|
-
config.
|
|
82
|
+
config.categories = {
|
|
83
|
+
turns: {limit: 40, per: :day},
|
|
84
|
+
tokens: {limit: 10_000, per: :day}
|
|
85
|
+
}
|
|
50
86
|
config.retention = 14 * 24 * 60 * 60 # seconds
|
|
51
87
|
end
|
|
52
88
|
|
|
@@ -77,14 +113,26 @@ response = agent.run(prompt)
|
|
|
77
113
|
budget.record!(guest_session, turns: 1, tokens: response.input_tokens + response.output_tokens)
|
|
78
114
|
```
|
|
79
115
|
|
|
80
|
-
|
|
81
|
-
left, and counters roll over
|
|
116
|
+
Categories compose — a guest is allowed only while every category has
|
|
117
|
+
budget left in its period, and counters roll over with no scheduled work.
|
|
118
|
+
Periods are `:day`, `:week`, or `:month`; any usage key can be metered,
|
|
119
|
+
including your own (uploads, parses, messages):
|
|
82
120
|
|
|
83
121
|
```ruby
|
|
84
|
-
config.
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
122
|
+
config.categories = {
|
|
123
|
+
turns: {limit: 40, per: :day},
|
|
124
|
+
tokens: {limit: 10_000, per: :day},
|
|
125
|
+
uploads: {limit: 5, per: :day},
|
|
126
|
+
messages: {limit: 100, per: :month},
|
|
127
|
+
parses: {limit: 20, per: :day, usage_key: :uploads} # meter one key as another category
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
budget = Ask::Guests.budget
|
|
131
|
+
budget.allowed?(guest) # all categories
|
|
132
|
+
budget.allowed?(guest, :turns) # one category
|
|
133
|
+
budget.remaining(guest, :turns) # => 12
|
|
134
|
+
budget.exceeded(guest) # => [:tokens]
|
|
135
|
+
budget.record!(guest, turns: 1, tokens: 850)
|
|
88
136
|
```
|
|
89
137
|
|
|
90
138
|
### Claiming on sign-up
|
|
@@ -113,7 +161,7 @@ Ask::Guests.sweep.call # destroys unclaimed sessions inactive past retention
|
|
|
113
161
|
```ruby
|
|
114
162
|
# app/models/guest_session.rb
|
|
115
163
|
class GuestSession < Ask::Guests::ActiveRecord::SessionBase
|
|
116
|
-
self.table_name = "
|
|
164
|
+
self.table_name = "ask_guest_sessions"
|
|
117
165
|
has_many :work_requests, foreign_key: :guest_session_id, dependent: :destroy
|
|
118
166
|
end
|
|
119
167
|
|
|
@@ -121,7 +169,7 @@ end
|
|
|
121
169
|
Ask::Guests.configure do |config|
|
|
122
170
|
config.secret = Rails.application.secret_key_base
|
|
123
171
|
config.store = Ask::Guests::ActiveRecord::SessionStore.new(model: GuestSession)
|
|
124
|
-
config.
|
|
172
|
+
config.categories = {turns: {limit: 40, per: :day}}
|
|
125
173
|
end
|
|
126
174
|
```
|
|
127
175
|
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# The ask_guest_sessions table.
|
|
2
|
+
#
|
|
3
|
+
# Shipped with the gem so the table has one definition: the install generator
|
|
4
|
+
# copies this file into a Rails app, and an app without Rails copies the same
|
|
5
|
+
# body into whatever migrates its database. A host that edits its copy keeps
|
|
6
|
+
# its own table — but the columns the store reads and writes are these.
|
|
7
|
+
#
|
|
8
|
+
# Named for the gem, like every other table this ecosystem ships: a host may
|
|
9
|
+
# well have its own `guest_sessions`, and the gem has no business claiming it.
|
|
10
|
+
#
|
|
11
|
+
# `t.json` rather than `jsonb` so the same migration runs on PostgreSQL,
|
|
12
|
+
# MySQL, and SQLite; nothing here queries inside the documents, so the jsonb
|
|
13
|
+
# operators buy nothing. Stamped 7.1 because that is the oldest Active Record
|
|
14
|
+
# the gem supports — a newer Rails runs it unchanged.
|
|
15
|
+
class CreateAskGuestSessions < ActiveRecord::Migration[7.1]
|
|
16
|
+
def change
|
|
17
|
+
create_table :ask_guest_sessions do |t|
|
|
18
|
+
t.json :counters, null: false, default: {}
|
|
19
|
+
t.json :metadata, null: false, default: {}
|
|
20
|
+
t.datetime :last_seen_at
|
|
21
|
+
t.datetime :converted_at
|
|
22
|
+
t.references :owner, polymorphic: true
|
|
23
|
+
t.timestamps
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Speculative: sessions at risk of being swept.
|
|
27
|
+
add_index :ask_guest_sessions, :converted_at
|
|
28
|
+
add_index :ask_guest_sessions, :last_seen_at
|
|
29
|
+
end
|
|
30
|
+
end
|
|
@@ -8,7 +8,7 @@ module Ask
|
|
|
8
8
|
# table created by the migration:
|
|
9
9
|
#
|
|
10
10
|
# class GuestSession < Ask::Guests::ActiveRecord::SessionBase
|
|
11
|
-
# self.table_name = "
|
|
11
|
+
# self.table_name = "ask_guest_sessions"
|
|
12
12
|
# has_many :work_requests, foreign_key: :guest_session_id, dependent: :destroy
|
|
13
13
|
# end
|
|
14
14
|
#
|
|
@@ -22,6 +22,14 @@ module Ask
|
|
|
22
22
|
class SessionBase < ::ActiveRecord::Base
|
|
23
23
|
self.abstract_class = true
|
|
24
24
|
|
|
25
|
+
# The table is named for the gem, the way the rest of the ecosystem's
|
|
26
|
+
# tables are: a host may have its own `guest_sessions`. A subclass that
|
|
27
|
+
# says nothing lands on ask_guest_sessions rather than claiming the
|
|
28
|
+
# generic name; one that sets table_name itself keeps its own.
|
|
29
|
+
def self.table_name_prefix
|
|
30
|
+
"ask_"
|
|
31
|
+
end
|
|
32
|
+
|
|
25
33
|
# The gem owns these columns' semantics; use the adapter-independent
|
|
26
34
|
# JSON type so PostgreSQL json/jsonb and SQLite text columns behave
|
|
27
35
|
# identically (and the model loads without a database connection).
|
|
@@ -6,7 +6,7 @@ module Ask
|
|
|
6
6
|
# ActiveRecord-backed session store.
|
|
7
7
|
#
|
|
8
8
|
# class GuestSession < Ask::Guests::ActiveRecord::SessionBase
|
|
9
|
-
# self.table_name = "
|
|
9
|
+
# self.table_name = "ask_guest_sessions"
|
|
10
10
|
# has_many :work_requests, foreign_key: :guest_session_id, dependent: :destroy
|
|
11
11
|
# end
|
|
12
12
|
#
|
|
@@ -26,6 +26,7 @@ module Ask
|
|
|
26
26
|
end
|
|
27
27
|
|
|
28
28
|
def create(attributes = {})
|
|
29
|
+
verify_schema!
|
|
29
30
|
attributes = attributes.to_h.transform_keys(&:to_sym)
|
|
30
31
|
now = Guests.configuration.now
|
|
31
32
|
record = model.new(
|
|
@@ -42,6 +43,7 @@ module Ask
|
|
|
42
43
|
def find(id)
|
|
43
44
|
return nil if id.nil?
|
|
44
45
|
|
|
46
|
+
verify_schema!
|
|
45
47
|
model.find_by(id: id)&.to_guest_session
|
|
46
48
|
end
|
|
47
49
|
|
|
@@ -69,6 +71,7 @@ module Ask
|
|
|
69
71
|
end
|
|
70
72
|
|
|
71
73
|
def sweep_stale(before:)
|
|
74
|
+
verify_schema!
|
|
72
75
|
destroyed = 0
|
|
73
76
|
stale_scope(before).find_each do |record|
|
|
74
77
|
record.destroy
|
|
@@ -77,6 +80,16 @@ module Ask
|
|
|
77
80
|
destroyed
|
|
78
81
|
end
|
|
79
82
|
|
|
83
|
+
# Raises MissingTable unless the sessions table is there. Called by the
|
|
84
|
+
# operations a host reaches first, so a forgotten migration names
|
|
85
|
+
# itself instead of surfacing as a database error about a relation.
|
|
86
|
+
# Idempotent and cheap: Active Record caches the schema.
|
|
87
|
+
def verify_schema!
|
|
88
|
+
return true if model.table_exists?
|
|
89
|
+
|
|
90
|
+
raise MissingTable, model.table_name
|
|
91
|
+
end
|
|
92
|
+
|
|
80
93
|
def transaction(&block)
|
|
81
94
|
model.transaction(&block)
|
|
82
95
|
end
|
|
@@ -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
|
data/lib/ask/guests/budget.rb
CHANGED
|
@@ -2,66 +2,104 @@
|
|
|
2
2
|
|
|
3
3
|
module Ask
|
|
4
4
|
module Guests
|
|
5
|
-
# Meters
|
|
6
|
-
# afterwards.
|
|
7
|
-
# while every
|
|
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)
|
|
11
|
-
# budget.
|
|
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
|
|
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 :
|
|
19
|
+
attr_reader :categories, :store
|
|
18
20
|
|
|
19
|
-
|
|
20
|
-
|
|
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
|
-
# @
|
|
26
|
-
|
|
27
|
-
|
|
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
|
-
|
|
35
|
+
selected(names).all? { |category| category.remaining(subject, now) > 0 }
|
|
30
36
|
end
|
|
31
37
|
|
|
32
|
-
#
|
|
33
|
-
#
|
|
34
|
-
def remaining(
|
|
35
|
-
return Float::INFINITY if
|
|
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
|
-
|
|
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
|
|
41
|
-
|
|
42
|
-
|
|
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
|
-
|
|
45
|
-
store.update(
|
|
46
|
-
|
|
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
|
-
|
|
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
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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
|
|
64
|
-
|
|
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
|
data/lib/ask/guests/errors.rb
CHANGED
|
@@ -11,5 +11,24 @@ module Ask
|
|
|
11
11
|
super
|
|
12
12
|
end
|
|
13
13
|
end
|
|
14
|
+
|
|
15
|
+
# Raised on the first store operation when the sessions table is not
|
|
16
|
+
# there. The gem ships the table, so a host that has not installed it
|
|
17
|
+
# should hear that — and the command that fixes it — rather than a
|
|
18
|
+
# database complaining about a relation nobody mentioned.
|
|
19
|
+
class MissingTable < Error
|
|
20
|
+
def initialize(table_name = "ask_guest_sessions")
|
|
21
|
+
super(<<~MESSAGE.strip)
|
|
22
|
+
The #{table_name} table does not exist.
|
|
23
|
+
|
|
24
|
+
Install the table the gem ships, then migrate:
|
|
25
|
+
bin/rails generate ask:guests:install
|
|
26
|
+
bin/rails db:migrate
|
|
27
|
+
|
|
28
|
+
Without Rails, copy db/migrate/*_create_ask_guest_sessions.rb from the gem
|
|
29
|
+
into your migrations and run them.
|
|
30
|
+
MESSAGE
|
|
31
|
+
end
|
|
32
|
+
end
|
|
14
33
|
end
|
|
15
34
|
end
|
data/lib/ask/guests/version.rb
CHANGED
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/
|
|
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
|
-
|
|
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
|
-
#
|
|
125
|
-
attr_accessor :
|
|
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
|
-
@
|
|
141
|
+
@categories = []
|
|
145
142
|
@retention = 14 * 24 * 60 * 60
|
|
146
143
|
@clock = -> { Time.now.utc }
|
|
147
144
|
@cookie_name = "guest_session_token"
|
|
@@ -1,65 +1,124 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "rails/generators"
|
|
4
|
+
require "rails/generators/migration"
|
|
4
5
|
|
|
5
6
|
module Ask
|
|
6
7
|
module Guests
|
|
7
8
|
module Generators
|
|
9
|
+
# Installs ask-guests into a Rails app: the ask_guest_sessions migration, the
|
|
10
|
+
# model that maps it, and an initializer to configure. Run it once, then
|
|
11
|
+
# edit what it wrote.
|
|
12
|
+
#
|
|
8
13
|
# bin/rails generate ask:guests:install
|
|
14
|
+
#
|
|
15
|
+
# The migrations are copied from the gem's own db/migrate, so the table
|
|
16
|
+
# has one definition — the one the store expects. A host that already
|
|
17
|
+
# migrated ask_guest_sessions keeps its file: an identical name is skipped
|
|
18
|
+
# rather than duplicated. Re-running the installer after an upgrade is
|
|
19
|
+
# how a host picks up a migration a later version added.
|
|
9
20
|
class InstallGenerator < ::Rails::Generators::Base
|
|
21
|
+
include Rails::Generators::Migration
|
|
22
|
+
|
|
10
23
|
source_root File.expand_path("templates", __dir__)
|
|
24
|
+
# The DDL lives in the gem's db/migrate, not in a second copy here.
|
|
25
|
+
source_paths << File.expand_path("../../../../../db/migrate", __dir__)
|
|
26
|
+
|
|
27
|
+
desc "Creates the ask_guest_sessions migration, the model, and the ask-guests initializer"
|
|
28
|
+
|
|
29
|
+
def self.next_migration_number(_dir)
|
|
30
|
+
Time.now.utc.strftime("%Y%m%d%H%M%S")
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# The migrations the gem ships, by file name.
|
|
34
|
+
def self.migrations
|
|
35
|
+
Dir[File.expand_path("../../../../../db/migrate/*.rb", __dir__)]
|
|
36
|
+
.map { |path| File.basename(path) }.sort
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def create_migrations
|
|
40
|
+
self.class.migrations.each do |migration|
|
|
41
|
+
next if installed?(migration) && !options[:force]
|
|
11
42
|
|
|
12
|
-
|
|
43
|
+
migration_template migration, "db/migrate/#{migration.sub(/\A\d+_/, "")}"
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def create_model
|
|
48
|
+
template "guest_session.rb", "app/models/guest_session.rb"
|
|
49
|
+
end
|
|
13
50
|
|
|
14
51
|
def create_initializer
|
|
15
52
|
template "initializer.rb", "config/initializers/ask_guests.rb"
|
|
16
53
|
end
|
|
17
54
|
|
|
18
|
-
def
|
|
19
|
-
|
|
20
|
-
|
|
55
|
+
def check_schema
|
|
56
|
+
if schema_installed?
|
|
57
|
+
say "ask_guest_sessions is in place.", :green
|
|
58
|
+
else
|
|
59
|
+
say "Run bin/rails db:migrate to create ask_guest_sessions.", :yellow
|
|
60
|
+
end
|
|
21
61
|
end
|
|
22
62
|
|
|
23
|
-
def
|
|
63
|
+
def show_next_steps
|
|
24
64
|
say <<~MESSAGE
|
|
25
65
|
|
|
26
66
|
ask-guests installed.
|
|
27
67
|
|
|
28
68
|
Next steps:
|
|
29
69
|
|
|
30
|
-
1.
|
|
70
|
+
1. Register what claiming transfers — one handler per record a
|
|
71
|
+
visitor owns (config/initializers/ask_guests.rb):
|
|
31
72
|
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
# Destroying a session sweeps everything the guest owned:
|
|
36
|
-
# has_many :work_requests, foreign_key: :guest_session_id, dependent: :destroy
|
|
73
|
+
Ask::Guests.claimable(:chats) do |session, owner|
|
|
74
|
+
Chat.where(guest_session_id: session.id)
|
|
75
|
+
.update_all(user_id: owner.id, guest_session_id: nil)
|
|
37
76
|
end
|
|
38
77
|
|
|
39
|
-
2.
|
|
40
|
-
|
|
41
|
-
3. Allow guests in a controller:
|
|
78
|
+
2. Let guests in, in the controller that serves them:
|
|
42
79
|
|
|
43
80
|
include Ask::Guests::Rails::Controller
|
|
81
|
+
|
|
44
82
|
allow_guest_access
|
|
45
|
-
before_action :require_visitor
|
|
83
|
+
before_action :require_visitor, only: %i[chat create]
|
|
46
84
|
|
|
47
|
-
|
|
85
|
+
...and key their records to #current_guest_session.
|
|
86
|
+
|
|
87
|
+
3. Claim on authentication (Devise example):
|
|
48
88
|
|
|
49
89
|
include Ask::Guests::Rails::ClaimOnAuthentication
|
|
50
90
|
after_action :claim_guest_session, only: :create
|
|
51
91
|
|
|
52
|
-
def guest_claim_owner = current_user
|
|
92
|
+
def guest_claim_owner = current_user
|
|
53
93
|
|
|
54
|
-
|
|
94
|
+
4. Sweep abandoned sessions on a schedule (config/recurring.yml
|
|
95
|
+
with Solid Queue):
|
|
55
96
|
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
schedule: every day at 4am
|
|
97
|
+
sweep_guest_sessions:
|
|
98
|
+
command: "Ask::Guests::Rails::SweepJob.perform_now"
|
|
99
|
+
schedule: every day at 4am
|
|
60
100
|
|
|
61
101
|
MESSAGE
|
|
62
102
|
end
|
|
103
|
+
|
|
104
|
+
private
|
|
105
|
+
|
|
106
|
+
# A migration the host already has is the host's — it may have run
|
|
107
|
+
# already, so the gem does not touch it unless asked with --force.
|
|
108
|
+
def installed?(migration)
|
|
109
|
+
name = migration.sub(/\A\d+_/, "").delete_suffix(".rb")
|
|
110
|
+
self.class.migration_exists?(File.join(destination_root, "db/migrate"), name)
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# Advisory only: a database that is not there yet (db:create has not
|
|
114
|
+
# run, or the app boots without one) is not this generator's problem.
|
|
115
|
+
def schema_installed?
|
|
116
|
+
return false unless defined?(::ActiveRecord::Base)
|
|
117
|
+
|
|
118
|
+
::ActiveRecord::Base.connection.table_exists?("ask_guest_sessions")
|
|
119
|
+
rescue ::ActiveRecord::ActiveRecordError
|
|
120
|
+
false
|
|
121
|
+
end
|
|
63
122
|
end
|
|
64
123
|
end
|
|
65
124
|
end
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
require "ask-guests/active_record"
|
|
2
|
+
|
|
3
|
+
# The ask_guest_sessions table, as the store reads and writes it.
|
|
4
|
+
#
|
|
5
|
+
# Nothing about a guest is a User row: a visitor is their own record, which is
|
|
6
|
+
# what lets an abandoned visit be swept and a visitor who signs up keep the
|
|
7
|
+
# threads they already had.
|
|
8
|
+
class GuestSession < Ask::Guests::ActiveRecord::SessionBase
|
|
9
|
+
self.table_name = "ask_guest_sessions"
|
|
10
|
+
|
|
11
|
+
# Everything a guest owns hangs off this row, so sweeping an abandoned
|
|
12
|
+
# session takes those records with it. Add one line per model that carries a
|
|
13
|
+
# guest_session_id — and make sure claiming moves them (see the initializer):
|
|
14
|
+
#
|
|
15
|
+
# has_many :chats, foreign_key: :guest_session_id, dependent: :destroy
|
|
16
|
+
end
|
|
@@ -1,39 +1,46 @@
|
|
|
1
|
-
|
|
1
|
+
require "ask-guests/active_record"
|
|
2
|
+
# The controller concern and the sweep job ride with Rails; the store is
|
|
3
|
+
# ActiveRecord's.
|
|
4
|
+
require "ask-guests/rails"
|
|
2
5
|
|
|
3
|
-
# Guest access
|
|
6
|
+
# Guest access (ask-guests).
|
|
4
7
|
#
|
|
5
|
-
# Anonymous visitors get a signed-cookie session, metered
|
|
6
|
-
#
|
|
7
|
-
#
|
|
8
|
+
# Anonymous visitors get a signed-cookie session, metered AI usage, and a
|
|
9
|
+
# single-use claim that moves everything they own to a real account when they
|
|
10
|
+
# sign up. See https://github.com/ask-rb/ask-guests for the full guide.
|
|
11
|
+
#
|
|
12
|
+
# Configured on prepare rather than at boot: the store holds the model class,
|
|
13
|
+
# and a reload must not leave it holding the class that was replaced.
|
|
14
|
+
Rails.application.config.to_prepare do
|
|
15
|
+
Ask::Guests.configure do |config|
|
|
16
|
+
# Required: HMAC secret for guest tokens. Rotating it invalidates all
|
|
17
|
+
# outstanding guest cookies (they simply start fresh).
|
|
18
|
+
config.secret = Rails.application.secret_key_base
|
|
8
19
|
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
# outstanding guest cookies (they simply start fresh).
|
|
12
|
-
config.secret = Rails.application.secret_key_base
|
|
20
|
+
# Persistence: the model the install generator wrote over the migration.
|
|
21
|
+
config.store = Ask::Guests::ActiveRecord::SessionStore.new(model: GuestSession)
|
|
13
22
|
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
23
|
+
# Usage caps for anonymous visitors, one entry per category. Signing up
|
|
24
|
+
# lifts them because a signed-in request never touches the guest budget.
|
|
25
|
+
# Periods are :day, :week, or :month; counters roll over automatically.
|
|
26
|
+
# Empty means unlimited — appropriate when the host bills the visit some
|
|
27
|
+
# other way, and a cap needs a state to show the visitor when it bites.
|
|
28
|
+
config.categories = {
|
|
29
|
+
turns: {limit: 40, per: :day}
|
|
30
|
+
# tokens: {limit: 10_000, per: :day},
|
|
31
|
+
# uploads: {limit: 5, per: :day},
|
|
32
|
+
# messages: {limit: 100, per: :month}
|
|
33
|
+
}
|
|
21
34
|
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
Ask::Guests::Budget::TurnLimit.new(per_day: 40)
|
|
26
|
-
# Ask::Guests::Budget::TokenQuota.new(per_day: 10_000)
|
|
27
|
-
]
|
|
28
|
-
|
|
29
|
-
# Unclaimed sessions inactive for this long are swept (seconds).
|
|
30
|
-
config.retention = 14 * 24 * 60 * 60
|
|
35
|
+
# Unclaimed sessions inactive for this long are swept (seconds).
|
|
36
|
+
config.retention = 14 * 24 * 60 * 60
|
|
37
|
+
end
|
|
31
38
|
|
|
32
39
|
# What claiming transfers. Register one handler per owned model; they run
|
|
33
40
|
# inside the claim's transaction, exactly once.
|
|
34
41
|
#
|
|
35
|
-
#
|
|
36
|
-
#
|
|
37
|
-
#
|
|
38
|
-
#
|
|
42
|
+
# Ask::Guests.claimable(:work_requests) do |session, owner|
|
|
43
|
+
# WorkRequest.where(guest_session_id: session.id)
|
|
44
|
+
# .update_all(account_id: owner.account_id, account_user_id: owner.id, guest_session_id: nil)
|
|
45
|
+
# end
|
|
39
46
|
end
|
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.
|
|
4
|
+
version: 0.3.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Kaka Ruto
|
|
@@ -93,6 +93,20 @@ dependencies:
|
|
|
93
93
|
- - ">="
|
|
94
94
|
- !ruby/object:Gem::Version
|
|
95
95
|
version: '7.1'
|
|
96
|
+
- !ruby/object:Gem::Dependency
|
|
97
|
+
name: railties
|
|
98
|
+
requirement: !ruby/object:Gem::Requirement
|
|
99
|
+
requirements:
|
|
100
|
+
- - ">="
|
|
101
|
+
- !ruby/object:Gem::Version
|
|
102
|
+
version: '7.1'
|
|
103
|
+
type: :development
|
|
104
|
+
prerelease: false
|
|
105
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
106
|
+
requirements:
|
|
107
|
+
- - ">="
|
|
108
|
+
- !ruby/object:Gem::Version
|
|
109
|
+
version: '7.1'
|
|
96
110
|
description: 'Give anonymous visitors a durable identity with metered AI access: signed
|
|
97
111
|
guest sessions, per-day turn/token budgets, single-use claiming of records into
|
|
98
112
|
a real owner on sign-up, and stale-session retention. Framework-agnostic core with
|
|
@@ -106,6 +120,7 @@ files:
|
|
|
106
120
|
- CHANGELOG.md
|
|
107
121
|
- LICENSE
|
|
108
122
|
- README.md
|
|
123
|
+
- db/migrate/20260917120000_create_ask_guest_sessions.rb
|
|
109
124
|
- lib/ask-guests.rb
|
|
110
125
|
- lib/ask-guests/active_record.rb
|
|
111
126
|
- lib/ask-guests/rails.rb
|
|
@@ -114,10 +129,7 @@ files:
|
|
|
114
129
|
- lib/ask/guests/active_record/session_base.rb
|
|
115
130
|
- lib/ask/guests/active_record/session_store.rb
|
|
116
131
|
- lib/ask/guests/budget.rb
|
|
117
|
-
- lib/ask/guests/budget/
|
|
118
|
-
- lib/ask/guests/budget/token_quota.rb
|
|
119
|
-
- lib/ask/guests/budget/turn_limit.rb
|
|
120
|
-
- lib/ask/guests/budget/unlimited.rb
|
|
132
|
+
- lib/ask/guests/budget/category.rb
|
|
121
133
|
- lib/ask/guests/claim.rb
|
|
122
134
|
- lib/ask/guests/errors.rb
|
|
123
135
|
- lib/ask/guests/rails/claim_on_authentication.rb
|
|
@@ -130,8 +142,8 @@ files:
|
|
|
130
142
|
- lib/ask/guests/token.rb
|
|
131
143
|
- lib/ask/guests/version.rb
|
|
132
144
|
- lib/generators/ask/guests/install/install_generator.rb
|
|
145
|
+
- lib/generators/ask/guests/install/templates/guest_session.rb
|
|
133
146
|
- lib/generators/ask/guests/install/templates/initializer.rb
|
|
134
|
-
- lib/generators/ask/guests/install/templates/migration.rb
|
|
135
147
|
homepage: https://github.com/ask-rb/ask-guests
|
|
136
148
|
licenses:
|
|
137
149
|
- MIT
|
|
@@ -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
|
|
@@ -1,16 +0,0 @@
|
|
|
1
|
-
class CreateGuestSessions < ActiveRecord::Migration[<%= ActiveRecord::Migration.current_version %>]
|
|
2
|
-
def change
|
|
3
|
-
create_table :guest_sessions do |t|
|
|
4
|
-
t.jsonb :counters, null: false, default: {}
|
|
5
|
-
t.jsonb :metadata, null: false, default: {}
|
|
6
|
-
t.datetime :last_seen_at
|
|
7
|
-
t.datetime :converted_at
|
|
8
|
-
t.references :owner, polymorphic: true
|
|
9
|
-
t.timestamps
|
|
10
|
-
end
|
|
11
|
-
|
|
12
|
-
# Speculative: sessions at risk of being swept.
|
|
13
|
-
add_index :guest_sessions, :converted_at
|
|
14
|
-
add_index :guest_sessions, :last_seen_at
|
|
15
|
-
end
|
|
16
|
-
end
|