ask-guests 0.1.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 +7 -0
- data/CHANGELOG.md +27 -0
- data/LICENSE +21 -0
- data/README.md +195 -0
- data/lib/ask/guests/active_record/json_value.rb +56 -0
- data/lib/ask/guests/active_record/session_base.rb +69 -0
- data/lib/ask/guests/active_record/session_store.rb +97 -0
- data/lib/ask/guests/budget/policy.rb +42 -0
- data/lib/ask/guests/budget/token_quota.rb +24 -0
- data/lib/ask/guests/budget/turn_limit.rb +24 -0
- data/lib/ask/guests/budget/unlimited.rb +16 -0
- data/lib/ask/guests/budget.rb +68 -0
- data/lib/ask/guests/claim.rb +49 -0
- data/lib/ask/guests/errors.rb +15 -0
- data/lib/ask/guests/rails/claim_on_authentication.rb +51 -0
- data/lib/ask/guests/rails/controller.rb +130 -0
- data/lib/ask/guests/rails/sweep_job.rb +24 -0
- data/lib/ask/guests/session.rb +103 -0
- data/lib/ask/guests/stores/base.rb +65 -0
- data/lib/ask/guests/stores/memory.rb +69 -0
- data/lib/ask/guests/sweep.rb +28 -0
- data/lib/ask/guests/token.rb +59 -0
- data/lib/ask/guests/version.rb +7 -0
- data/lib/ask/guests.rb +158 -0
- data/lib/ask-guests/active_record.rb +8 -0
- data/lib/ask-guests/rails.rb +9 -0
- data/lib/ask-guests.rb +3 -0
- data/lib/generators/ask/guests/install/install_generator.rb +66 -0
- data/lib/generators/ask/guests/install/templates/initializer.rb +39 -0
- data/lib/generators/ask/guests/install/templates/migration.rb +16 -0
- metadata +159 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 47d3aa99bde7a4fba72632740871294a132d06a492d679c78d5487daa5857506
|
|
4
|
+
data.tar.gz: 6f937c5d724bd72b036faed46a1490129b37ee59380da63d8ba781b2f243b9db
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 7691132899688d326b7014fd010a7887a107958e717ea1da66b7640fe2fb4b4ace845d0f6c5db23f483904ff13e00d19fe4510b69539a2b1ad91a0cf18c9a429
|
|
7
|
+
data.tar.gz: 7667b6be0f7ab4634020198f0e30566ea1b3338b79747017c3c7460e8910ca664bbf7222c41902f013d0a161e84481df09bf35b1e69c610ca34756e7c701b72a
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## [0.1.0] — 2026-09-15
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
|
|
7
|
+
- **Guest sessions** — durable anonymous identity: a random session id behind
|
|
8
|
+
an HMAC-SHA256 signed token (`Ask::Guests::Token`), with pluggable stores
|
|
9
|
+
(`Stores::Memory`, plus an ActiveRecord adapter).
|
|
10
|
+
- **Metered AI usage** — daily, composable budget policies
|
|
11
|
+
(`Budget::TurnLimit`, `Budget::TokenQuota`, `Budget::Unlimited`) that check
|
|
12
|
+
before a turn and record after; counters roll over daily with no scheduled
|
|
13
|
+
work.
|
|
14
|
+
- **Single-use claiming** — register handlers with `Ask::Guests.claimable`;
|
|
15
|
+
`Ask::Guests::Claim` transfers every guest-owned record to a real owner in
|
|
16
|
+
one transaction, re-reading the session under a lock so replayed cookies
|
|
17
|
+
and double-submits can never claim twice.
|
|
18
|
+
- **Retention** — `Ask::Guests::Sweep` destroys unclaimed sessions inactive
|
|
19
|
+
past the configured window (seconds); `Rails::SweepJob` schedules it.
|
|
20
|
+
- **Rails adapter** (`ask-guests/rails`) — `Rails::Controller` concern
|
|
21
|
+
(`allow_guest_access`, `require_visitor`, `guest_session?`),
|
|
22
|
+
`Rails::ClaimOnAuthentication` for sign-up/sign-in hooks, and the install
|
|
23
|
+
generator (`bin/rails generate ask:guests:install`).
|
|
24
|
+
- **ActiveRecord adapter** (`ask-guests/active_record`) — `SessionStore` and
|
|
25
|
+
an abstract `SessionBase` model with an adapter-independent JSON column
|
|
26
|
+
type (PostgreSQL json/jsonb and SQLite text behave identically) and
|
|
27
|
+
polymorphic owner columns.
|
data/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Kaka Ruto
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in
|
|
13
|
+
all copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
|
21
|
+
THE SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
# ask-guests
|
|
2
|
+
|
|
3
|
+
Anonymous, cookie-backed guest access for ask-rb apps. Give visitors a
|
|
4
|
+
durable identity *before* they sign up — with metered AI usage, and a
|
|
5
|
+
single-use claim that moves everything they own into a real account the
|
|
6
|
+
moment they register.
|
|
7
|
+
|
|
8
|
+
Four concepts, one gem:
|
|
9
|
+
|
|
10
|
+
| Concept | What it does |
|
|
11
|
+
|---|---|
|
|
12
|
+
| **Identity** | A random session id behind an HMAC-signed token. No fake `User` rows, nothing to enumerate without the secret. |
|
|
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. |
|
|
15
|
+
| **Claim** | On sign-up/sign-in, transfer all guest records in one transaction, exactly once. |
|
|
16
|
+
|
|
17
|
+
Plus retention: unclaimed sessions are swept after a configurable window.
|
|
18
|
+
|
|
19
|
+
The core is framework-agnostic (stdlib only). Adapters plug in persistence
|
|
20
|
+
and web frameworks:
|
|
21
|
+
|
|
22
|
+
```ruby
|
|
23
|
+
require "ask-guests" # core: sessions, budgets, claims, sweep
|
|
24
|
+
require "ask-guests/active_record" # ActiveRecord store + model base
|
|
25
|
+
require "ask-guests/rails" # controller concern, auth hook, sweep job
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
## Installation
|
|
29
|
+
|
|
30
|
+
```ruby
|
|
31
|
+
gem "ask-guests"
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
In a Rails app, run the installer:
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
bin/rails generate ask:guests:install
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
It creates the `guest_sessions` migration and an initializer, and prints
|
|
41
|
+
the model and controller wiring.
|
|
42
|
+
|
|
43
|
+
## Core usage
|
|
44
|
+
|
|
45
|
+
```ruby
|
|
46
|
+
Ask::Guests.configure do |config|
|
|
47
|
+
config.secret = ENV.fetch("GUEST_TOKEN_SECRET")
|
|
48
|
+
config.store = Ask::Guests::Stores::Memory.new
|
|
49
|
+
config.policies = [Ask::Guests::Budget::TurnLimit.new(per_day: 40)]
|
|
50
|
+
config.retention = 14 * 24 * 60 * 60 # seconds
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Mint a session and hand the visitor a token (put it in a cookie yourself,
|
|
54
|
+
# or use the Rails adapter):
|
|
55
|
+
session = Ask::Guests.store.create(metadata: {ip_address: request.ip})
|
|
56
|
+
token = Ask::Guests::Token.sign(session.id)
|
|
57
|
+
cookies[:guest] = token
|
|
58
|
+
|
|
59
|
+
# Resolve a returning visitor:
|
|
60
|
+
id = Ask::Guests::Token.verify(cookies[:guest]) # => session id or nil
|
|
61
|
+
session = Ask::Guests.store.find(id) # => Session or nil
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
### Metering AI usage
|
|
65
|
+
|
|
66
|
+
The budget is checked before a turn and recorded after — works with any
|
|
67
|
+
agent runtime, including ask-agent streams:
|
|
68
|
+
|
|
69
|
+
```ruby
|
|
70
|
+
budget = Ask::Guests.budget
|
|
71
|
+
|
|
72
|
+
unless budget.allowed?(guest_session)
|
|
73
|
+
return render_quota_exceeded # "create an account to keep going"
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
response = agent.run(prompt)
|
|
77
|
+
budget.record!(guest_session, turns: 1, tokens: response.input_tokens + response.output_tokens)
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
Policies compose — a guest is allowed only while every policy has budget
|
|
81
|
+
left, and counters roll over daily with no scheduled work:
|
|
82
|
+
|
|
83
|
+
```ruby
|
|
84
|
+
config.policies = [
|
|
85
|
+
Ask::Guests::Budget::TurnLimit.new(per_day: 40),
|
|
86
|
+
Ask::Guests::Budget::TokenQuota.new(per_day: 10_000)
|
|
87
|
+
]
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
### Claiming on sign-up
|
|
91
|
+
|
|
92
|
+
Register one handler per owned model. Handlers run inside the claim's
|
|
93
|
+
transaction, after the session has been re-read under a lock — a replayed
|
|
94
|
+
cookie or double-submit can never claim twice:
|
|
95
|
+
|
|
96
|
+
```ruby
|
|
97
|
+
Ask::Guests.claimable(:work_requests) do |session, owner|
|
|
98
|
+
WorkRequest.where(guest_session_id: session.id)
|
|
99
|
+
.update_all(account_id: owner.account_id, account_user_id: owner.id, guest_session_id: nil)
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
Ask::Guests::Claim.new(session: guest_session, owner: account_user).call # => true
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
### Retention
|
|
106
|
+
|
|
107
|
+
```ruby
|
|
108
|
+
Ask::Guests.sweep.call # destroys unclaimed sessions inactive past retention
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
## Rails
|
|
112
|
+
|
|
113
|
+
```ruby
|
|
114
|
+
# app/models/guest_session.rb
|
|
115
|
+
class GuestSession < Ask::Guests::ActiveRecord::SessionBase
|
|
116
|
+
self.table_name = "guest_sessions"
|
|
117
|
+
has_many :work_requests, foreign_key: :guest_session_id, dependent: :destroy
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# config/initializers/ask_guests.rb
|
|
121
|
+
Ask::Guests.configure do |config|
|
|
122
|
+
config.secret = Rails.application.secret_key_base
|
|
123
|
+
config.store = Ask::Guests::ActiveRecord::SessionStore.new(model: GuestSession)
|
|
124
|
+
config.policies = [Ask::Guests::Budget::TurnLimit.new(per_day: 40)]
|
|
125
|
+
end
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
Controllers:
|
|
129
|
+
|
|
130
|
+
```ruby
|
|
131
|
+
class WorkRequestsController < ApplicationController
|
|
132
|
+
include Ask::Guests::Rails::Controller
|
|
133
|
+
|
|
134
|
+
allow_guest_access # resume the cookie on every request
|
|
135
|
+
before_action :require_visitor # user or guest — never nobody
|
|
136
|
+
end
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
Views get `guest_session?` and `current_guest_session` as helpers.
|
|
140
|
+
|
|
141
|
+
Claim on authentication (Devise shown; any auth works):
|
|
142
|
+
|
|
143
|
+
```ruby
|
|
144
|
+
class Users::RegistrationsController < Devise::RegistrationsController
|
|
145
|
+
include Ask::Guests::Rails::ClaimOnAuthentication
|
|
146
|
+
|
|
147
|
+
after_action :claim_guest_session, only: :create
|
|
148
|
+
|
|
149
|
+
private
|
|
150
|
+
|
|
151
|
+
def guest_claim_owner = current_user.account_users.first
|
|
152
|
+
end
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
Schedule the sweep (`config/recurring.yml` for Solid Queue):
|
|
156
|
+
|
|
157
|
+
```yaml
|
|
158
|
+
production:
|
|
159
|
+
guests_sweep:
|
|
160
|
+
class: "Ask::Guests::Rails::SweepJob"
|
|
161
|
+
schedule: every day at 4am
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
Destroying a session destroys its row, so associations declared with
|
|
165
|
+
`dependent: :destroy` sweep everything the guest owned.
|
|
166
|
+
|
|
167
|
+
## Custom stores
|
|
168
|
+
|
|
169
|
+
Implement `Ask::Guests::Stores::Base` (`create`, `find`, `update`,
|
|
170
|
+
`destroy`, `sweep_stale`, and optionally `lock`/`transaction`) and point
|
|
171
|
+
`config.store` at it. Stores return `Ask::Guests::Session` value objects
|
|
172
|
+
and treat ids as opaque strings.
|
|
173
|
+
|
|
174
|
+
## Hard-won details
|
|
175
|
+
|
|
176
|
+
- **Budget is checked before anything is persisted** — a rejected turn
|
|
177
|
+
leaves no trace, so "create an account to continue" is honest.
|
|
178
|
+
- **Capabilities stay server-side.** Metering bounds cost; what a guest
|
|
179
|
+
*cannot do* (submit work, trigger notifications) belongs in your tool
|
|
180
|
+
implementations, not the prompt.
|
|
181
|
+
- **`config.secret` rotation** invalidates outstanding guest cookies —
|
|
182
|
+
visitors simply start fresh sessions.
|
|
183
|
+
- **Touch throttling**: `last_seen_at` is written at most once per
|
|
184
|
+
`touch_interval` (default 300s) so page views don't write per request.
|
|
185
|
+
|
|
186
|
+
## Development
|
|
187
|
+
|
|
188
|
+
```bash
|
|
189
|
+
bundle install
|
|
190
|
+
bundle exec rake test
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
## License
|
|
194
|
+
|
|
195
|
+
MIT.
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module Ask
|
|
6
|
+
module Guests
|
|
7
|
+
module ActiveRecord
|
|
8
|
+
# Adapter-independent JSON attribute type.
|
|
9
|
+
#
|
|
10
|
+
# ActiveRecord's built-in +:json+ attribute type resolves against the
|
|
11
|
+
# database adapter at class-definition time (so the model cannot even
|
|
12
|
+
# be loaded before a connection exists, and behavior differs between
|
|
13
|
+
# PostgreSQL jsonb and SQLite text). This type does the same job with
|
|
14
|
+
# deterministic behavior everywhere:
|
|
15
|
+
#
|
|
16
|
+
# * reads Hash (PostgreSQL jsonb) as-is
|
|
17
|
+
# * parses String (SQLite text) into a Hash
|
|
18
|
+
# * writes a JSON string
|
|
19
|
+
class JsonValue < ActiveModel::Type::Value
|
|
20
|
+
def type
|
|
21
|
+
:json
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def cast(value)
|
|
25
|
+
normalize(value)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def deserialize(value)
|
|
29
|
+
normalize(value)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def serialize(value)
|
|
33
|
+
normalize(value).to_json
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
private
|
|
37
|
+
|
|
38
|
+
def normalize(value)
|
|
39
|
+
case value
|
|
40
|
+
when nil then nil
|
|
41
|
+
when String then parse(value)
|
|
42
|
+
else value
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def parse(string)
|
|
47
|
+
return nil if string.strip.empty?
|
|
48
|
+
|
|
49
|
+
JSON.parse(string)
|
|
50
|
+
rescue JSON::ParserError
|
|
51
|
+
nil
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ask
|
|
4
|
+
module Guests
|
|
5
|
+
module ActiveRecord
|
|
6
|
+
# Abstract ActiveRecord model for guest sessions. Subclass it in the
|
|
7
|
+
# host app (usually via the install generator), mapping it to the
|
|
8
|
+
# table created by the migration:
|
|
9
|
+
#
|
|
10
|
+
# class GuestSession < Ask::Guests::ActiveRecord::SessionBase
|
|
11
|
+
# self.table_name = "guest_sessions"
|
|
12
|
+
# has_many :work_requests, foreign_key: :guest_session_id, dependent: :destroy
|
|
13
|
+
# end
|
|
14
|
+
#
|
|
15
|
+
# Expected columns:
|
|
16
|
+
# counters - json/jsonb, daily usage counters ("{}" default)
|
|
17
|
+
# metadata - json/jsonb, free-form host data ("{}" default)
|
|
18
|
+
# last_seen_at - datetime, updated on visitor activity
|
|
19
|
+
# converted_at - datetime, set on claim; NULL means still a guest
|
|
20
|
+
# owner_type - string, polymorphic owner set on claim
|
|
21
|
+
# owner_id - bigint, polymorphic owner set on claim
|
|
22
|
+
class SessionBase < ::ActiveRecord::Base
|
|
23
|
+
self.abstract_class = true
|
|
24
|
+
|
|
25
|
+
# The gem owns these columns' semantics; use the adapter-independent
|
|
26
|
+
# JSON type so PostgreSQL json/jsonb and SQLite text columns behave
|
|
27
|
+
# identically (and the model loads without a database connection).
|
|
28
|
+
attribute :counters, JsonValue.new, default: {}
|
|
29
|
+
attribute :metadata, JsonValue.new, default: {}
|
|
30
|
+
|
|
31
|
+
belongs_to :owner, polymorphic: true, optional: true
|
|
32
|
+
|
|
33
|
+
def converted?
|
|
34
|
+
!converted_at.nil?
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def unclaimed?
|
|
38
|
+
!converted?
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# @return [Ask::Guests::Session]
|
|
42
|
+
def to_guest_session
|
|
43
|
+
Guests::Session.new(
|
|
44
|
+
id: id,
|
|
45
|
+
created_at: created_at,
|
|
46
|
+
last_seen_at: last_seen_at,
|
|
47
|
+
converted_at: converted_at,
|
|
48
|
+
owner_type: owner_type,
|
|
49
|
+
owner_id: owner_id,
|
|
50
|
+
counters: counters || {},
|
|
51
|
+
metadata: metadata || {}
|
|
52
|
+
)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Writes a value object's state back onto this record (without
|
|
56
|
+
# saving). Used by SessionStore#update.
|
|
57
|
+
def apply_guest_session(session)
|
|
58
|
+
self.counters = session.counters
|
|
59
|
+
self.metadata = session.metadata
|
|
60
|
+
self.last_seen_at = session.last_seen_at
|
|
61
|
+
self.converted_at = session.converted_at
|
|
62
|
+
self.owner_type = session.owner_type
|
|
63
|
+
self.owner_id = session.owner_id
|
|
64
|
+
self
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ask
|
|
4
|
+
module Guests
|
|
5
|
+
module ActiveRecord
|
|
6
|
+
# ActiveRecord-backed session store.
|
|
7
|
+
#
|
|
8
|
+
# class GuestSession < Ask::Guests::ActiveRecord::SessionBase
|
|
9
|
+
# self.table_name = "guest_sessions"
|
|
10
|
+
# has_many :work_requests, foreign_key: :guest_session_id, dependent: :destroy
|
|
11
|
+
# end
|
|
12
|
+
#
|
|
13
|
+
# Ask::Guests.configure do |config|
|
|
14
|
+
# config.store = Ask::Guests::ActiveRecord::SessionStore.new(model: GuestSession)
|
|
15
|
+
# end
|
|
16
|
+
#
|
|
17
|
+
# Destroying a session destroys the record, so host associations
|
|
18
|
+
# declared with +dependent: :destroy+ sweep owned records too.
|
|
19
|
+
class SessionStore < Stores::Base
|
|
20
|
+
attr_reader :model
|
|
21
|
+
|
|
22
|
+
# @param model [Class] an ActiveRecord model class with the columns
|
|
23
|
+
# documented on SessionBase
|
|
24
|
+
def initialize(model:)
|
|
25
|
+
@model = model
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def create(attributes = {})
|
|
29
|
+
attributes = attributes.to_h.transform_keys(&:to_sym)
|
|
30
|
+
now = Guests.configuration.now
|
|
31
|
+
record = model.new(
|
|
32
|
+
counters: attributes[:counters] || {},
|
|
33
|
+
metadata: attributes[:metadata] || {},
|
|
34
|
+
last_seen_at: attributes[:last_seen_at] || now,
|
|
35
|
+
created_at: attributes[:created_at] || now
|
|
36
|
+
)
|
|
37
|
+
record.id = attributes[:id] if attributes[:id]
|
|
38
|
+
record.save!
|
|
39
|
+
record.to_guest_session
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def find(id)
|
|
43
|
+
return nil if id.nil?
|
|
44
|
+
|
|
45
|
+
model.find_by(id: id)&.to_guest_session
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Re-reads the session with a row lock where the database supports
|
|
49
|
+
# it, for race-safe claiming.
|
|
50
|
+
def lock(id)
|
|
51
|
+
return nil if id.nil?
|
|
52
|
+
|
|
53
|
+
scope = model.lock
|
|
54
|
+
scope.find_by(id: id)&.to_guest_session
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def update(session)
|
|
58
|
+
record = model.find_by(id: session.id)
|
|
59
|
+
return session if record.nil?
|
|
60
|
+
|
|
61
|
+
record.apply_guest_session(session)
|
|
62
|
+
record.save!
|
|
63
|
+
session
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def destroy(session)
|
|
67
|
+
model.find_by(id: session.id)&.destroy
|
|
68
|
+
session
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def sweep_stale(before:)
|
|
72
|
+
destroyed = 0
|
|
73
|
+
stale_scope(before).find_each do |record|
|
|
74
|
+
record.destroy
|
|
75
|
+
destroyed += 1
|
|
76
|
+
end
|
|
77
|
+
destroyed
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def transaction(&block)
|
|
81
|
+
model.transaction(&block)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def supports_transactions?
|
|
85
|
+
true
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
private
|
|
89
|
+
|
|
90
|
+
def stale_scope(before)
|
|
91
|
+
model.where(converted_at: nil)
|
|
92
|
+
.where("COALESCE(last_seen_at, created_at) < ?", before)
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
|
@@ -0,0 +1,42 @@
|
|
|
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
|
|
@@ -0,0 +1,24 @@
|
|
|
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
|
|
@@ -0,0 +1,24 @@
|
|
|
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
|
|
@@ -0,0 +1,16 @@
|
|
|
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
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ask
|
|
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.
|
|
8
|
+
#
|
|
9
|
+
# budget = Ask::Guests.budget
|
|
10
|
+
# budget.allowed?(session) # => true/false
|
|
11
|
+
# budget.remaining(session) # => Integer (minimum across policies)
|
|
12
|
+
# budget.record!(session, turns: 1, tokens: 850)
|
|
13
|
+
#
|
|
14
|
+
# Counters live on the session and roll over daily, so "per day" limits
|
|
15
|
+
# reset without any scheduled work.
|
|
16
|
+
class Budget
|
|
17
|
+
attr_reader :policies, :store
|
|
18
|
+
|
|
19
|
+
def initialize(policies: nil, store: Guests.store, clock: Guests.configuration.clock)
|
|
20
|
+
@policies = normalize(policies)
|
|
21
|
+
@store = store
|
|
22
|
+
@clock = clock
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# @return [Boolean] true while every policy has budget left
|
|
26
|
+
def allowed?(session)
|
|
27
|
+
return true if session.nil?
|
|
28
|
+
|
|
29
|
+
policies.all? { |policy| policy.remaining(session, today) > 0 }
|
|
30
|
+
end
|
|
31
|
+
|
|
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?
|
|
36
|
+
|
|
37
|
+
policies.map { |policy| policy.remaining(session, today) }.min
|
|
38
|
+
end
|
|
39
|
+
|
|
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?
|
|
43
|
+
|
|
44
|
+
policies.each { |policy| policy.record!(session, turns: turns, tokens: tokens, today: today) }
|
|
45
|
+
store.update(session)
|
|
46
|
+
session
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def unlimited?
|
|
50
|
+
policies.all? { |policy| policy.remaining(Session.new(id: "probe"), today) == Float::INFINITY }
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
private
|
|
54
|
+
|
|
55
|
+
def normalize(policies)
|
|
56
|
+
case policies
|
|
57
|
+
when nil then [Budget::Unlimited.new]
|
|
58
|
+
when Array then policies
|
|
59
|
+
else [policies]
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def today
|
|
64
|
+
@clock.call.utc.strftime("%Y-%m-%d")
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|