ask-tokens-rails 0.2.2 → 0.4.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: 8670efe472c23b2b9f14c0fe8fe4a941adbb77d5c7e1d0dfa32150c1fdad8049
4
- data.tar.gz: d47f088143f600f04d2f89d0a58046f7160f28b841cfef73da3c9616656e8e24
3
+ metadata.gz: fe4e15b2393197cf4983d5db195ed7e4d513de3593b553b4359f4a5abd114883
4
+ data.tar.gz: 56f42f53d6899ea2c7fdba9d8e664cf083398abe0b510f207663e817564387b2
5
5
  SHA512:
6
- metadata.gz: 994761b2ad77166078a7750c92194bd217a67fb4280bbf51bb5e43ec97af10fc1bf0e8781529237b6689f43d74857d95235c689927fdb63dee3ae8f87a436676
7
- data.tar.gz: a192e5b37cbf5b800b39701d1ab33f390f8b1cfbbce9a20f2e6d65e37a111750e1adb8d01a0f6283b56677b7bfb0859437745ec63b088ba4d2c87a9b83d73e38
6
+ metadata.gz: bcb48f78eed19e4777b2236b7b0a98a53645173a122845db5f8b26c35c7e9f004f98766889b2b366d94814820e1d7d3cb5fdec9d68081aa28c2e3912ec518b14
7
+ data.tar.gz: 2f5d0a2ae26cd3997b2964c4e6d804b757382b5db26af238ae2a5cd809e616e3c16d8bfece4446bf289c17d5bca56a0853ee197c0f2ea1b69cfe4b41526b4e80
data/CHANGELOG.md CHANGED
@@ -1,3 +1,81 @@
1
+ ## [0.4.0] — 2026-09-14
2
+
3
+ ### Added
4
+
5
+ - **Billing-detail columns on the ledger.** `model_id`, `provider`,
6
+ `input_tokens`, `output_tokens`, `cached_tokens`, `llm_cost_usd`, and
7
+ `multiplier` — already lifted out of `metadata` by the ActiveRecord store —
8
+ are now in the install migration, so ledgers can be summed and grouped
9
+ (model spend, margin) without JSON casts. The columns are optional: the
10
+ store intersects the extraction keys with the table's actual columns, so
11
+ installs created before this release keep the detail in `metadata`.
12
+ Existing installs can add the columns with the equivalent migration.
13
+ - **`credits` scope and `debit?`/`credit?` predicates** on the ledger model,
14
+ matching the `debits`/`grants` scopes.
15
+ - **`has_token_wallet` macro.** Including `Ask::Tokens::Rails::HasTokenWallet`
16
+ still works; the railtie also makes the documented `has_token_wallet` macro
17
+ available on every model.
18
+ - **`expires_at` index** on the transactions table, for the sweep job.
19
+
20
+ ### Fixed
21
+
22
+ - **`SweepExpiredTokensJob` never loaded and had a broken query.** The job is
23
+ now required by the railtie, and its grant lookup no longer hardcodes the
24
+ legacy `token_transactions` table or a nonexistent `source_transaction_id`
25
+ column (the source grant lives in `metadata`).
26
+ - **Expiry no longer eats a later top-up.** The sweep replays the wallet's
27
+ ledger first-to-expire-first and removes only each expired grant's unspent
28
+ remainder, instead of capping at whatever the wallet balance happens to be.
29
+ Repeated runs are idempotent.
30
+
31
+ ## [0.3.0] — 2026-09-14
32
+
33
+ ### Changed
34
+
35
+
36
+
37
+ - **Default table names are now gem-prefixed: `ask_tokens_wallets` and
38
+
39
+ `ask_tokens_transactions`.** They can no longer collide with an app's own
40
+
41
+ ledger, so no app configuration is needed. To keep the old names (or adopt
42
+
43
+ existing tables), configure them:
44
+
45
+
46
+
47
+ ```ruby
48
+
49
+ Ask::Tokens::Rails.configure do |config|
50
+
51
+ config.wallets_table = "token_wallets"
52
+
53
+ config.transactions_table = "token_transactions"
54
+
55
+ end
56
+
57
+ ```
58
+
59
+
60
+
61
+ Existing installs must either set the configuration above or rename their
62
+
63
+ tables in a migration.
64
+
65
+
66
+
67
+ ### Added
68
+
69
+
70
+
71
+ - `Ask::Tokens::Rails.configure` and `reset_config!` — configurable wallet
72
+
73
+ and transaction table names, honoured by the models and the install
74
+
75
+ generator.
76
+
77
+
78
+
1
79
  # Changelog
2
80
 
3
81
  ## [0.1.0] - 2026-08-19
data/README.md CHANGED
@@ -1,22 +1,31 @@
1
- # ask-token-usage-rails
1
+ # ask-tokens-rails
2
2
 
3
- ActiveRecord persistence for the [ask-token-usage](https://github.com/ask-rb/ask-token-usage) wallet engine.
3
+ ActiveRecord persistence for the [ask-tokens](https://github.com/ask-rb/ask-tokens) wallet engine.
4
4
 
5
- Ships an ActiveRecord store adapter, a `has_token_wallet` concern, install generator with migrations, and an expiry sweep job.
5
+ Ships an ActiveRecord store adapter, a `has_token_wallet` macro, install generator with migrations, and an expiry sweep job.
6
6
 
7
7
  ## Installation
8
8
 
9
9
  ```ruby
10
10
  # Gemfile
11
- gem "ask-token-usage-rails"
11
+ gem "ask-tokens-rails"
12
12
  ```
13
13
 
14
14
  ```sh
15
15
  bundle install
16
- rails g ask_token_usage:install
16
+ rails g ask_tokens:install
17
17
  rails db:migrate
18
18
  ```
19
19
 
20
+ Table names default to `ask_tokens_wallets` and `ask_tokens_transactions`. To adopt existing tables:
21
+
22
+ ```ruby
23
+ Ask::Tokens::Rails.configure do |config|
24
+ config.wallets_table = "token_wallets"
25
+ config.transactions_table = "token_transactions"
26
+ end
27
+ ```
28
+
20
29
  ## Usage
21
30
 
22
31
  ```ruby
@@ -35,19 +44,25 @@ end
35
44
  # Check balance
36
45
  user.token_balance # => 9_998
37
46
 
47
+ # Read the ledger
48
+ user.token_transactions.credits
49
+ user.token_transactions.debits.since(30.days.ago)
50
+
38
51
  # Use the PORO wallet directly
39
52
  wallet = user.ask_token_wallet
40
53
  wallet.entries
41
54
  wallet.used_since(30.days.ago)
42
55
  ```
43
56
 
57
+ Pass `metadata: {model_id:, provider:, input_tokens:, output_tokens:, cached_tokens:, llm_cost_usd:, multiplier:}` on a grant/deduction and the store lifts those keys into the ledger's billing columns, so model spend and margin can be summed without JSON casts.
58
+
44
59
  ## Scheduled jobs
45
60
 
46
- `SweepExpiredTokensJob` expires grant entries whose `expires_at` has passed.
61
+ `SweepExpiredTokensJob` expires grant entries whose `expires_at` has passed, removing only each grant's unspent remainder (first-to-expire-first allocation). Add an `expires_at` index if your install predates migration column v0.4.0.
47
62
 
48
63
  ```yaml
49
64
  # config/recurring.yml (Solid Queue)
50
- ask_token_usage_sweep:
51
- class: Ask::TokenUsage::Rails::SweepExpiredTokensJob
52
- schedule: "every 1 hour"
65
+ ask_tokens_sweep:
66
+ class: Ask::Tokens::Rails::SweepExpiredTokensJob
67
+ schedule: every hour
53
68
  ```
@@ -5,6 +5,14 @@ require "active_support/concern"
5
5
  module Ask
6
6
  module Tokens
7
7
  module Rails
8
+ # Included into ActiveRecord::Base by the railtie, so any model can say
9
+ # `has_token_wallet` — the macro documented in the README.
10
+ module TokenWalletOwner
11
+ def has_token_wallet
12
+ include Ask::Tokens::Rails::HasTokenWallet
13
+ end
14
+ end
15
+
8
16
  # Include this concern in any ActiveRecord model to give it a token
9
17
  # wallet backed by ask-tokens-rails.
10
18
  #
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ask
4
+ module Tokens
5
+ module Rails
6
+ # Table names are prefixed with the gem's name by default, so they can
7
+ # never collide with an app's own ledger — no configuration needed.
8
+ # Override only to adopt existing tables:
9
+ #
10
+ # Ask::Tokens::Rails.configure do |config|
11
+ # config.wallets_table = "token_wallets"
12
+ # config.transactions_table = "token_transactions"
13
+ # end
14
+ class Configuration
15
+ attr_accessor :wallets_table, :transactions_table
16
+
17
+ def initialize
18
+ @wallets_table = "ask_tokens_wallets"
19
+ @transactions_table = "ask_tokens_transactions"
20
+ end
21
+ end
22
+
23
+ class << self
24
+ def config
25
+ @config ||= Configuration.new
26
+ end
27
+
28
+ def configure
29
+ yield config
30
+ end
31
+
32
+ def reset_config!
33
+ @config = Configuration.new
34
+ end
35
+ end
36
+ end
37
+ end
38
+ end
@@ -8,67 +8,109 @@ module Ask
8
8
  # Expire grant entries whose +expires_at+ has passed. Scheduled as a
9
9
  # recurring job (Solid Queue cron, Sidekiq Cron, etc.):
10
10
  #
11
- # # config/recurring.yml
11
+ # # config/recurring.yml (Solid Queue)
12
12
  # ask_tokens_sweep:
13
13
  # class: Ask::Tokens::Rails::SweepExpiredTokensJob
14
- # schedule: "every 1 hour"
14
+ # schedule: every hour
15
15
  #
16
- # For each expired grant, the actual amount removed is capped to the
17
- # wallet's current balance (myrr approximation — correct for most
18
- # grant-heavy usage patterns; perfect accuracy requires FIFO allocation
19
- # which is a future option).
16
+ # Grants are consumed first-to-expire-first: a debit draws from the
17
+ # grant that will expire soonest (non-expiring grants last). Replaying
18
+ # the wallet's ledger therefore tells us exactly how much of each
19
+ # expired grant is left unspent — only that remainder is removed, so
20
+ # expiry can never eat a later top-up. Recorded expiry rows count as
21
+ # consumption of their source grant, which makes repeated runs
22
+ # idempotent.
20
23
  #
21
24
  class SweepExpiredTokensJob < ActiveJob::Base
22
25
  queue_as :default
23
26
 
24
27
  def perform(now: Time.current)
25
- Ask::Tokens::TokenTransaction
28
+ wallet_ids = Ask::Tokens::TokenTransaction
26
29
  .grants
27
30
  .where("expires_at IS NOT NULL AND expires_at <= ?", now)
28
- .where.not(entry_type: "expiry")
29
- .where("id NOT IN (SELECT source_transaction_id FROM token_transactions WHERE entry_type = ?)", "expiry")
30
- .find_each do |grant|
31
- sweep_grant(grant)
32
- end
31
+ .distinct
32
+ .pluck(:token_wallet_id)
33
+
34
+ Ask::Tokens::TokenWallet.where(id: wallet_ids).find_each do |wallet|
35
+ sweep_wallet(wallet, now: now)
36
+ end
33
37
  end
34
38
 
35
39
  private
36
40
 
37
- def sweep_grant(grant)
38
- wallet = grant.token_wallet
39
- return unless wallet
40
-
41
+ def sweep_wallet(wallet, now:)
41
42
  wallet.with_lock do
42
- remaining = grant.amount - already_expired_amount(grant)
43
- return if remaining <= 0
44
-
45
- actual = [remaining, wallet.balance].min
46
- return if actual <= 0
47
-
48
- Ask::Tokens::TokenTransaction.create!(
49
- token_wallet_id: wallet.id,
50
- entry_type: "expiry",
51
- amount: -actual,
52
- reason: "token_expiry",
53
- balance: wallet.balance - actual,
54
- metadata: {
55
- source_transaction_id: grant.id,
56
- expired_amount: remaining,
57
- actual_expired: actual
58
- },
59
- created_at: now
60
- )
61
-
62
- wallet.update_column(:balance, wallet.balance - actual)
43
+ expired = expired_remainders(wallet, now: now)
44
+ return if expired.empty?
45
+
46
+ balance = wallet.balance
47
+ expired.each do |grant, remaining|
48
+ actual = [remaining, balance].min
49
+ next if actual <= 0
50
+
51
+ Ask::Tokens::TokenTransaction.create!(
52
+ token_wallet_id: wallet.id,
53
+ entry_type: "expiry",
54
+ amount: -actual,
55
+ reason: "token_expiry",
56
+ balance: balance - actual,
57
+ metadata: {
58
+ source_transaction_id: grant.id,
59
+ expired_amount: remaining,
60
+ actual_expired: actual
61
+ },
62
+ created_at: now
63
+ )
64
+ balance -= actual
65
+ end
66
+
67
+ wallet.update_column(:balance, balance)
63
68
  end
64
69
  end
65
70
 
66
- def already_expired_amount(grant)
67
- Ask::Tokens::TokenTransaction
68
- .where(token_wallet_id: grant.token_wallet_id, entry_type: "expiry")
69
- .where("metadata->>'source_transaction_id' = ?", grant.id.to_s)
70
- .sum(:amount)
71
- .abs
71
+ # [[grant, unspent_remainder], ...] for every grant whose window has
72
+ # closed and that still has tokens left after replaying the wallet's
73
+ # ledger.
74
+ def expired_remainders(wallet, now:)
75
+ rows = Ask::Tokens::TokenTransaction
76
+ .where(token_wallet_id: wallet.id)
77
+ .order(:created_at, :id)
78
+ .to_a
79
+
80
+ lots = rows
81
+ .select { |row| row.entry_type == "grant" }
82
+ .sort_by { |grant| [grant.expires_at ? 0 : 1, grant.expires_at || grant.created_at, grant.id] }
83
+ .map { |grant| {grant: grant, remaining: grant.amount} }
84
+ by_grant_id = lots.index_by { |lot| lot[:grant].id }
85
+
86
+ rows.each do |row|
87
+ case row.entry_type
88
+ when "debit"
89
+ consume(lots, -row.amount)
90
+ when "expiry"
91
+ lot = by_grant_id[row.metadata["source_transaction_id"].to_i]
92
+ consume_lot(lot, -row.amount) if lot
93
+ end
94
+ end
95
+
96
+ lots
97
+ .select { |lot| lot[:remaining] > 0 && lot[:grant].expires_at && lot[:grant].expires_at <= now }
98
+ .map { |lot| [lot[:grant], lot[:remaining]] }
99
+ end
100
+
101
+ def consume(lots, amount)
102
+ lots.each do |lot|
103
+ amount = consume_lot(lot, amount)
104
+ break if amount <= 0
105
+ end
106
+ end
107
+
108
+ def consume_lot(lot, amount)
109
+ return amount if lot.nil? || amount <= 0
110
+
111
+ taken = [lot[:remaining], amount].min
112
+ lot[:remaining] -= taken
113
+ amount - taken
72
114
  end
73
115
  end
74
116
  end
@@ -1,13 +1,16 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "active_record"
4
+ require_relative "../configuration"
4
5
 
5
6
  module Ask
6
7
  module Tokens
7
8
  # Append-only ledger row. Every grant, debit, adjustment, or expiry is
8
9
  # written here as an immutable record. Never updated or deleted.
9
10
  class TokenTransaction < ::ActiveRecord::Base
10
- self.table_name = "token_transactions"
11
+ def self.table_name
12
+ Ask::Tokens::Rails.config.transactions_table
13
+ end
11
14
 
12
15
  belongs_to :token_wallet, class_name: "Ask::Tokens::TokenWallet",
13
16
  foreign_key: :token_wallet_id,
@@ -22,9 +25,19 @@ module Ask
22
25
  scope :debits, -> { where(entry_type: "debit") }
23
26
  scope :adjustments, -> { where(entry_type: "adjustment") }
24
27
  scope :expiries, -> { where(entry_type: "expiry") }
28
+ # Everything that adds tokens to a wallet (grants and adjustments).
29
+ scope :credits, -> { where(entry_type: %w[grant adjustment]) }
25
30
  scope :since, ->(time) { where("created_at >= ?", time) }
26
31
  scope :newest_first, -> { order(created_at: :desc) }
27
32
 
33
+ def debit?
34
+ entry_type == "debit"
35
+ end
36
+
37
+ def credit?
38
+ %w[grant adjustment].include?(entry_type)
39
+ end
40
+
28
41
  validate :immutable_after_creation, on: :update
29
42
 
30
43
  private
@@ -1,12 +1,15 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "active_record"
4
+ require_relative "../configuration"
4
5
 
5
6
  module Ask
6
7
  module Tokens
7
8
  # Polymorphic wallet record. One per owner. Holds the cached balance.
8
9
  class TokenWallet < ::ActiveRecord::Base
9
- self.table_name = "token_wallets"
10
+ def self.table_name
11
+ Ask::Tokens::Rails.config.wallets_table
12
+ end
10
13
 
11
14
  has_many :token_transactions, class_name: "Ask::Tokens::TokenTransaction",
12
15
  foreign_key: :token_wallet_id,
@@ -11,11 +11,18 @@ module Ask
11
11
  require "ask/tokens/rails/models/token_transaction"
12
12
  require "ask/tokens/rails/stores/active_record_store"
13
13
  require "ask/tokens/rails/concerns/has_token_wallet"
14
+ require "ask/tokens/rails/jobs/sweep_expired_tokens_job"
14
15
 
15
16
  Ask::Tokens.configure do |c|
16
17
  c.store = Ask::Tokens::Rails::ActiveRecordStore.new
17
18
  end
18
19
  end
20
+
21
+ initializer "ask_tokens.has_token_wallet_macro" do
22
+ ActiveSupport.on_load(:active_record) do
23
+ extend Ask::Tokens::Rails::TokenWalletOwner
24
+ end
25
+ end
19
26
  end
20
27
  end
21
28
  end
@@ -94,10 +94,14 @@ module Ask
94
94
  # Extract well-known metadata keys into their matching model columns.
95
95
  # Only writes keys that exist in metadata, so grants/debits without
96
96
  # LLM context are unaffected. Symbol and string keys are both handled.
97
+ # Intersected with the table's actual columns so installs created
98
+ # before the billing columns existed keep the detail in metadata.
97
99
  COLUMN_KEYS = %i[model_id provider input_tokens output_tokens cached_tokens llm_cost_usd multiplier].freeze
98
100
 
99
101
  def extract_columns_from_metadata(meta)
100
- COLUMN_KEYS.each_with_object({}) do |key, hash|
102
+ keys = COLUMN_KEYS & Ask::Tokens::TokenTransaction.column_names.map(&:to_sym)
103
+
104
+ keys.each_with_object({}) do |key, hash|
101
105
  value = meta[key] || meta[key.to_s]
102
106
  hash[key] = value if value
103
107
  end
@@ -3,7 +3,7 @@
3
3
  module Ask
4
4
  module Tokens
5
5
  module Rails
6
- VERSION = "0.2.2"
6
+ VERSION = "0.4.0"
7
7
  end
8
8
  end
9
9
  end
@@ -2,4 +2,5 @@
2
2
 
3
3
  require "ask-tokens"
4
4
  require "ask/tokens/rails/version"
5
+ require "ask/tokens/rails/configuration"
5
6
  require "ask/tokens/rails/railtie"
@@ -1,5 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "ask-tokens-rails"
3
4
  require "rails/generators"
4
5
  require "rails/generators/migration"
5
6
 
@@ -1,25 +1,37 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- class CreateTokenWallets < ActiveRecord::Migration[7.0]
3
+ class CreateAskTokensTables < ActiveRecord::Migration[7.0]
4
4
  def change
5
- create_table :token_wallets do |t|
5
+ create_table :<%= Ask::Tokens::Rails.config.wallets_table %> do |t|
6
6
  t.references :owner, polymorphic: true, null: false
7
7
  t.bigint :balance, null: false, default: 0
8
8
  t.timestamps
9
9
  end
10
- add_index :token_wallets, %i[owner_type owner_id], unique: true, name: "idx_token_wallets_owner"
10
+ add_index :<%= Ask::Tokens::Rails.config.wallets_table %>, %i[owner_type owner_id], unique: true,
11
+ name: "idx_<%= Ask::Tokens::Rails.config.wallets_table %>_owner"
11
12
 
12
- create_table :token_transactions do |t|
13
- t.references :token_wallet, null: false, foreign_key: true
13
+ create_table :<%= Ask::Tokens::Rails.config.transactions_table %> do |t|
14
+ t.references :token_wallet, null: false, foreign_key: {to_table: Ask::Tokens::Rails.config.wallets_table}
14
15
  t.string :entry_type, null: false
15
16
  t.bigint :amount, null: false
16
17
  t.string :reason, null: false
17
18
  t.jsonb :metadata, null: false, default: {}
18
19
  t.bigint :balance, null: false
19
20
  t.datetime :expires_at
21
+ # Billing detail the ActiveRecord store lifts out of :metadata when
22
+ # present. Columns exist so ledgers can be summed and grouped (model
23
+ # spend, margin) without JSON casts.
24
+ t.string :model_id
25
+ t.string :provider
26
+ t.bigint :input_tokens
27
+ t.bigint :output_tokens
28
+ t.bigint :cached_tokens
29
+ t.decimal :llm_cost_usd, precision: 12, scale: 8
30
+ t.decimal :multiplier, precision: 6, scale: 4, default: 1.0, null: false
20
31
  t.datetime :created_at, null: false
21
32
  end
22
- add_index :token_transactions, %i[token_wallet_id created_at]
23
- add_index :token_transactions, :entry_type
33
+ add_index :<%= Ask::Tokens::Rails.config.transactions_table %>, %i[token_wallet_id created_at]
34
+ add_index :<%= Ask::Tokens::Rails.config.transactions_table %>, :entry_type
35
+ add_index :<%= Ask::Tokens::Rails.config.transactions_table %>, :expires_at
24
36
  end
25
37
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: ask-tokens-rails
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.2
4
+ version: 0.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Kaka Ruto
@@ -93,6 +93,7 @@ files:
93
93
  - README.md
94
94
  - lib/ask-tokens-rails.rb
95
95
  - lib/ask/tokens/rails/concerns/has_token_wallet.rb
96
+ - lib/ask/tokens/rails/configuration.rb
96
97
  - lib/ask/tokens/rails/jobs/sweep_expired_tokens_job.rb
97
98
  - lib/ask/tokens/rails/models/token_transaction.rb
98
99
  - lib/ask/tokens/rails/models/token_wallet.rb