ask-token-usage 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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: f04950df36b2add93c891d335a6a924d15fb8d3624fc968d91a5799dc1b0631d
4
+ data.tar.gz: 1f712474ef58866ddc0da6e36b08ac1a456fcf48e53ead880a48b0bd8ecb5d48
5
+ SHA512:
6
+ metadata.gz: ede62ff37ca85eb92d4280cdfc0b69ea8d48e0bbcc5b69de6cd93b7116aefd5b42d15d8a589d3b6522d608dd4b2effc6d7e6e2f8d08659eef795fad3751981f1
7
+ data.tar.gz: 1e3851ffa4a7edee4c29ec1c4ed230c241760608a4cad6d152b2d974de91b3a2da296640318146f0ab18d8a9d2083d7969e64436bf250d1cf3fbcdd9d14db9db
data/CHANGELOG.md ADDED
@@ -0,0 +1,16 @@
1
+ # Changelog
2
+
3
+ ## [0.1.0] - 2026-08-19
4
+
5
+ ### Added
6
+ - `Ask::TokenUsage` — pure-Ruby token accounting foundation. No Rails dependency.
7
+ - Token counting via tiktoken_ruby (`count_tokens`, model-aware encoding, cl100k_base fallback).
8
+ - Pricing via Money gem (`price`, `tokens_for`, `cents_for`) at a configurable per-million rate.
9
+ - Activity DSL: declare fixed or dynamic costs; block receives params and returns an integer.
10
+ - Wallet engine: `grant!`, `deduct!`, `spend!` (block form for atomic deduct-on-success), `balance`, `has?`, `estimate`, `entries`, `used_since`.
11
+ - `LedgerEntry` immutable value object (grant/debit/adjustment/expiry).
12
+ - `Stores::Memory` in-memory store with a reentrant monitor for concurrent access.
13
+ - `Stores::Store` port for custom backends.
14
+ - Callbacks: `on_grant`, `on_spend`, `on_insufficient` with `CallbackContext`.
15
+ - Configuration: store, rounding, negatives, currency, price_per_1m, time provider.
16
+ - Standard ask-gem infrastructure: CI matrix (Ruby 3.2–3.4), rubocop, overcommit, SimpleCov toggle.
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 all
13
+ 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 THE
21
+ SOFTWARE.
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ask
4
+ module TokenUsage
5
+ # A named activity with a token cost.
6
+ #
7
+ # Ask::TokenUsage.activity(:document_render, cost: 10)
8
+ # Ask::TokenUsage.activity(:chat_message) do |params|
9
+ # Ask::TokenUsage.count_tokens(params[:input]) + ...
10
+ # end
11
+ #
12
+ class Activity
13
+ attr_reader :name
14
+
15
+ def initialize(name, cost: nil, &block)
16
+ raise ArgumentError, "activity #{name.inspect} needs a cost or a block" if cost.nil? && block.nil?
17
+
18
+ @name = name.to_sym
19
+ @cost = cost
20
+ @block = block
21
+ end
22
+
23
+ # The token cost of running this activity with +params+. Always returns
24
+ # an Integer, rounded per configuration. A fixed cost ignores params; a
25
+ # block receives them (zero-arity blocks are called with no arguments).
26
+ def cost(params = {})
27
+ raw = if @block
28
+ @block.arity.zero? ? @block.call : @block.call(params)
29
+ elsif @cost.respond_to?(:call)
30
+ @cost.arity.zero? ? @cost.call : @cost.call(params)
31
+ else
32
+ @cost
33
+ end
34
+
35
+ Ask::TokenUsage.config.round(raw)
36
+ end
37
+
38
+ def to_s
39
+ "Activity(#{@name})"
40
+ end
41
+
42
+ def inspect
43
+ to_s
44
+ end
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ask
4
+ module TokenUsage
5
+ # Stores declared activities by name.
6
+ class ActivityRegistry
7
+ def initialize
8
+ @activities = {}
9
+ end
10
+
11
+ def register(name, cost: nil, &block)
12
+ activity = Activity.new(name, cost: cost, &block)
13
+ @activities[activity.name] = activity
14
+ end
15
+
16
+ def fetch(name)
17
+ @activities.fetch(name.to_sym) do
18
+ raise KeyError, "Unknown activity: #{name.inspect}. Registered: #{@activities.keys.inspect}"
19
+ end
20
+ end
21
+
22
+ def names
23
+ @activities.keys
24
+ end
25
+
26
+ def each(&block)
27
+ @activities.each_value(&block)
28
+ end
29
+
30
+ def clear
31
+ @activities.clear
32
+ end
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ask
4
+ module TokenUsage
5
+ # Payload delivered to configured callbacks (on_grant, on_spend,
6
+ # on_insufficient).
7
+ #
8
+ CallbackContext = Data.define(
9
+ :owner, # the wallet owner identifier
10
+ :event, # :grant | :spend | :insufficient
11
+ :amount, # signed integer
12
+ :reason, # string
13
+ :metadata, # hash
14
+ :entry, # the LedgerEntry written for :grant/:spend
15
+ :previous_balance,
16
+ :new_balance
17
+ ) do
18
+ def insufficient?
19
+ event == :insufficient
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,114 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "money"
4
+
5
+ module Ask
6
+ module TokenUsage
7
+ # Global configuration for ask-token-usage.
8
+ #
9
+ # Ask::TokenUsage.configure do |config|
10
+ # config.store = Ask::TokenUsage::Stores::Memory.new
11
+ # config.price_per_token = 0.0001 # $0.0001 per token
12
+ # config.rounding = :ceil
13
+ # config.negatives = false
14
+ # config.currency = "USD"
15
+ # end
16
+ #
17
+ class Configuration
18
+ # The store backing every wallet. Defaults to a process-wide in-memory
19
+ # store; swap for an ActiveRecord-backed store via ask-token-usage-rails.
20
+ attr_accessor :store
21
+
22
+ # Token-cost rounding: :ceil (default), :floor, or :round.
23
+ attr_accessor :rounding
24
+
25
+ # Allow balances to go below zero. Defaults to false.
26
+ attr_accessor :negatives
27
+
28
+ # ISO currency code used by pricing. Defaults to "USD".
29
+ attr_accessor :currency
30
+
31
+ # Hook called after tokens are granted. Receives a CallbackContext.
32
+ attr_writer :on_grant
33
+
34
+ # Hook called after tokens are deducted. Receives a CallbackContext.
35
+ attr_writer :on_spend
36
+
37
+ # Hook called when balance is manually adjusted. Receives a CallbackContext.
38
+ attr_writer :on_adjust
39
+
40
+ # Hook called when a spend is rejected for insufficient balance.
41
+ # Receives a CallbackContext (executed before InsufficientTokens raises).
42
+ attr_writer :on_insufficient
43
+
44
+ # Injectable time source for deterministic expiry tests.
45
+ attr_writer :time
46
+
47
+ def initialize
48
+ @store = Stores::Memory.new
49
+ @rounding = :ceil
50
+ @negatives = false
51
+ @currency = "USD"
52
+ @on_grant = nil
53
+ @on_spend = nil
54
+ @on_adjust = nil
55
+ @on_insufficient = nil
56
+ @time = -> { Time.now }
57
+ end
58
+
59
+ # Set the price of a single billing token in USD (or your currency).
60
+ # Provide a numeric value or a Money object.
61
+ #
62
+ # config.price_per_token = 0.0001 # $0.0001 / token
63
+ # config.price_per_token = Money.from_amount(0.0001, "USD")
64
+ #
65
+ def price_per_token=(value)
66
+ @price_per_token = value.is_a?(Money) ? value : Money.from_amount(value.to_d, currency)
67
+ end
68
+
69
+ # The configured Money value of one billing token. Raises when unset.
70
+ def price_per_token
71
+ return @price_per_token if defined?(@price_per_token) && @price_per_token
72
+
73
+ raise Error, "Ask::TokenUsage.configure { |c| c.price_per_token = ... } is not set; pricing needs it"
74
+ end
75
+
76
+ def time(&block)
77
+ @time = block if block
78
+ @time
79
+ end
80
+
81
+ def on_grant(&block)
82
+ @on_grant = block if block
83
+ @on_grant
84
+ end
85
+
86
+ def on_spend(&block)
87
+ @on_spend = block if block
88
+ @on_spend
89
+ end
90
+
91
+ def on_adjust(&block)
92
+ @on_adjust = block if block
93
+ @on_adjust
94
+ end
95
+
96
+ def on_insufficient(&block)
97
+ @on_insufficient = block if block
98
+ @on_insufficient
99
+ end
100
+
101
+ def round(amount)
102
+ number = amount.to_d
103
+ case rounding
104
+ when :ceil then number.ceil
105
+ when :floor then number.floor
106
+ when :round then number.round
107
+ else number.to_i
108
+ end
109
+ end
110
+ end
111
+
112
+ class Error < StandardError; end
113
+ end
114
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "tiktoken_ruby"
4
+
5
+ module Ask
6
+ module TokenUsage
7
+ # Real token measurement via tiktoken_ruby (OpenAI's BPE tokenizer).
8
+ # This is what turns "a page of text" into a countable, sellable number.
9
+ #
10
+ module Counting
11
+ DEFAULT_ENCODING = "cl100k_base"
12
+
13
+ module_function
14
+
15
+ # Count the tokens in +text+. Pass +model:+ for a model-aware encoding.
16
+ # Unknown models fall back to the default encoding; if even tiktoken
17
+ # fails we estimate at ~4 chars per token rather than blowing up.
18
+ def count(text, model: nil)
19
+ encoding_for(model).encode(text.to_s).length
20
+ rescue StandardError
21
+ estimate(text.to_s)
22
+ end
23
+
24
+ def encoding_for(model)
25
+ if model
26
+ enc = Tiktoken.encoding_for_model(model)
27
+ return enc if enc
28
+ end
29
+
30
+ Tiktoken.get_encoding(DEFAULT_ENCODING)
31
+ end
32
+
33
+ # Coarse fallback estimate (~4 characters per token).
34
+ def estimate(text)
35
+ (text.to_s.length / 4.0).ceil
36
+ end
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,83 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ask
4
+ module TokenUsage
5
+ # Optional bridge to ask-instrumentation events. When installed, every
6
+ # LLM chat completion (chat.ask / chat.stream.ask) automatically deducts
7
+ # the measured token cost from the caller's wallet.
8
+ #
9
+ # require "ask/token_usage/instrumentation"
10
+ #
11
+ # Ask::TokenUsage::Instrumentation.install do |event|
12
+ # # Return the owner (wallet key) for this LLM call, or nil to skip.
13
+ # event.payload[:workspace] # whatever object/identifier you key wallets on
14
+ # end
15
+ #
16
+ # The block receives every matching instrumentation event and must return
17
+ # either the wallet owner or nil. Token cost is computed from the
18
+ # +input_tokens+/+output_tokens+ reported in the event payload.
19
+ #
20
+ # Requires ask-instrumentation as a runtime dependency. The gem works
21
+ # without it; this file is only loaded when explicitly required.
22
+ #
23
+ module Instrumentation
24
+ PATTERNS = %w[chat.ask chat.stream.ask].freeze
25
+
26
+ module_function
27
+
28
+ # Subscribe to ask-instrumentation chat events. +&block+ receives
29
+ # each event and must return the wallet owner (for the wallet) or nil
30
+ # to skip deduction. Idempotent — calling install twice is safe.
31
+ def install(&block)
32
+ return if @installed
33
+
34
+ raise ArgumentError, "block is required — it maps each event to a wallet owner" unless block
35
+
36
+ @owner_resolver = block
37
+ @installed = true
38
+
39
+ ActiveSupport::Notifications.subscribe(/chat(\.stream)?\.ask/) do |_name, _start, _finish, _id, payload|
40
+ handle_event(payload)
41
+ end
42
+ end
43
+
44
+ def uninstall
45
+ ActiveSupport::Notifications.unsubscribe(/chat(\.stream)?\.ask/)
46
+ @owner_resolver = nil
47
+ @installed = false
48
+ end
49
+
50
+ def installed?
51
+ @installed
52
+ end
53
+
54
+ def handle_event(payload)
55
+ owner = @owner_resolver.call(payload)
56
+ return unless owner
57
+
58
+ wallet = Ask::TokenUsage.wallet_for(owner)
59
+ input = payload[:usage] ? payload[:usage][:input_tokens].to_i : payload[:input_tokens].to_i
60
+ output = payload[:usage] ? payload[:usage][:output_tokens].to_i : payload[:output_tokens].to_i
61
+ tokens = input + output
62
+ return if tokens <= 0
63
+
64
+ model = payload[:model] || payload[:model_id]
65
+ provider = payload[:provider]
66
+
67
+ wallet.deduct!(tokens,
68
+ reason: :llm_call,
69
+ metadata: {
70
+ input_tokens: input,
71
+ output_tokens: output,
72
+ model_id: model,
73
+ provider: provider
74
+ })
75
+ rescue InsufficientTokens
76
+ # let the caller handle insufficient — this module only auto-deducts
77
+ # when the wallet can cover the cost.
78
+ rescue StandardError => e
79
+ warn "[ask-token-usage instrumentation] failed to deduct: #{e.class}: #{e.message}"
80
+ end
81
+ end
82
+ end
83
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ask
4
+ module TokenUsage
5
+ # Raised when a spend is attempted but the wallet has insufficient tokens.
6
+ # Also fired to the configured on_insufficient callback before raising.
7
+ #
8
+ class InsufficientTokens < StandardError
9
+ attr_reader :required, :available
10
+
11
+ def initialize(required:, available:)
12
+ @required = required
13
+ @available = available
14
+ super("Insufficient tokens: need #{required}, have #{available}")
15
+ end
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ask
4
+ module TokenUsage
5
+ # An immutable entry in a wallet's ledger.
6
+ #
7
+ # +kind+ is one of:
8
+ # :grant — tokens added (carries +expires_at+ when expiring)
9
+ # :debit — tokens spent (negative +amount+)
10
+ # :adjustment — manual correction
11
+ # :expiry — tokens removed because their grant expired
12
+ #
13
+ # +amount+ is signed: positive for grants/adjustments, negative for
14
+ # debits and expiries. +balance+ is the wallet's running balance after
15
+ # the entry.
16
+ #
17
+ LedgerEntry = Data.define(:id, :kind, :amount, :reason, :expires_at, :metadata, :balance, :created_at) do
18
+ def grant?
19
+ kind == :grant
20
+ end
21
+
22
+ def debit?
23
+ kind == :debit
24
+ end
25
+
26
+ def adjustment?
27
+ kind == :adjustment
28
+ end
29
+
30
+ def expiry?
31
+ kind == :expiry
32
+ end
33
+
34
+ def credit?
35
+ amount.positive?
36
+ end
37
+
38
+ def debit_amount
39
+ amount.negative? ? amount.abs : 0
40
+ end
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "money"
4
+
5
+ module Ask
6
+ module TokenUsage
7
+ # Price conversions built on the configured per-token rate.
8
+ #
9
+ # Ask::TokenUsage.configure { |c| c.price_per_token = 0.0001 }
10
+ # Ask::TokenUsage.price_per_token # => Money($0.0001)
11
+ # Ask::TokenUsage.price_per(1_000) # => Money($0.10)
12
+ # Ask::TokenUsage.tokens_for(10) # => 100_000
13
+ #
14
+ module Pricing
15
+ module_function
16
+
17
+ # Price of +count+ tokens at the configured rate.
18
+ #
19
+ # Ask::TokenUsage.price_per(1_000) # => Money($0.10)
20
+ # Ask::TokenUsage.price_per(1_000_000) # => Money($100.00)
21
+ #
22
+ def price_of(count)
23
+ Ask::TokenUsage.config.price_per_token * count.to_i
24
+ end
25
+
26
+ # How many billing tokens a Money (or numeric dollar) amount buys.
27
+ def tokens_for(money)
28
+ amount = money.is_a?(Money) ? money : Money.from_amount(money.to_d, Ask::TokenUsage.config.currency)
29
+ return 0 if amount.negative? || amount.zero?
30
+
31
+ (amount / Ask::TokenUsage.config.price_per_token).to_i
32
+ end
33
+
34
+ # The price of +count+ tokens in the currency's minor units (cents).
35
+ def cents_for(count, currency: nil)
36
+ price_of(count).cents.to_i
37
+ end
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "store"
4
+ require "monitor"
5
+
6
+ module Ask
7
+ module TokenUsage
8
+ module Stores
9
+ # In-memory store: per-process state keyed by owner, guarded by one
10
+ # reentrant monitor. Perfect for scripts, tests, and demos. Every wallet
11
+ # built without a configured store shares this class's process state via
12
+ # the default Configuration store.
13
+ class Memory < Store
14
+ def initialize
15
+ @monitor = Monitor.new
16
+ @balances = Hash.new(0)
17
+ @ledgers = Hash.new { |hash, key| hash[key] = [] }
18
+ @next_id = 0
19
+ end
20
+
21
+ def balance(owner)
22
+ @balances[owner]
23
+ end
24
+
25
+ def entries(owner, kind: nil, since: nil)
26
+ result = @ledgers[owner]
27
+ result = result.select { |entry| entry.kind == kind } if kind
28
+ result = result.select { |entry| entry.created_at >= since } if since
29
+ result
30
+ end
31
+
32
+ def with_lock(owner)
33
+ @monitor.synchronize { yield }
34
+ end
35
+
36
+ def append(owner, entry)
37
+ with_lock(owner) do
38
+ @next_id += 1
39
+ persisted = entry.with(id: @next_id)
40
+ @ledgers[owner] << persisted
41
+ persisted
42
+ end
43
+ end
44
+
45
+ def write_balance(owner, balance)
46
+ with_lock(owner) do
47
+ @balances[owner] = balance
48
+ end
49
+ end end
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ask
4
+ module TokenUsage
5
+ module Stores
6
+ # The persistence port the wallet engine talks to. The wallet never
7
+ # touches a database — it goes through this small interface, so the
8
+ # engine is testable with Stores::Memory and portable to any backend.
9
+ #
10
+ # Implementations must be safe under concurrent access: two wallets
11
+ # mutating the same owner must never corrupt the balance.
12
+ #
13
+ class Store
14
+ # Current balance (Integer) for +owner+, or 0.
15
+ def balance(owner)
16
+ raise NotImplementedError
17
+ end
18
+
19
+ # Ledger entries for +owner+, oldest first, filtered by kind and time.
20
+ def entries(owner, kind: nil, since: nil)
21
+ raise NotImplementedError
22
+ end
23
+
24
+ # Yields once with exclusive access to +owner+'s balance. All
25
+ # read-check-write sequences must happen inside this block.
26
+ def with_lock(owner)
27
+ raise NotImplementedError
28
+ end
29
+
30
+ # Persist +entry+ (an unpersisted LedgerEntry) for +owner+ and return
31
+ # the persisted copy (with id/created_at). Must be called inside
32
+ # with_lock.
33
+ def append(owner, entry)
34
+ raise NotImplementedError
35
+ end
36
+
37
+ # Persist the owner's cached balance. Must be called inside with_lock.
38
+ def write_balance(owner, balance)
39
+ raise NotImplementedError
40
+ end
41
+ end
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ask
4
+ module TokenUsage
5
+ VERSION = "0.1.0"
6
+ end
7
+ end
@@ -0,0 +1,200 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ask
4
+ module TokenUsage
5
+ # The wallet engine: all the business logic of token accounting, written
6
+ # once against the Store port. No database knowledge lives here.
7
+ #
8
+ # wallet = Ask::TokenUsage.wallet_for("user:42")
9
+ # wallet.grant!(1000, reason: :signup, expires_at: 7.days.from_now)
10
+ # wallet.spend!(:chat_message, input: "hi", output: "yo") { LLM.chat(...) }
11
+ # wallet.balance # => 998
12
+ #
13
+ class Wallet
14
+ attr_reader :owner
15
+
16
+ def initialize(owner:, store:, time: -> { Time.now })
17
+ @owner = owner
18
+ @store = store
19
+ @time = time
20
+ end
21
+
22
+ # ---- Reads -----------------------------------------------------------
23
+
24
+ # Current token balance.
25
+ def balance
26
+ @store.balance(owner).to_i
27
+ end
28
+
29
+ # Ledger entries, oldest first. Filter by :kind (grant/debit/...) and
30
+ # :since (Time).
31
+ def entries(**filters)
32
+ @store.entries(owner, **filters)
33
+ end
34
+
35
+ # True when the balance covers +amount+.
36
+ def has?(amount)
37
+ balance >= amount.to_i
38
+ end
39
+
40
+ # Estimated token cost of running +activity+ with +params+.
41
+ def estimate(activity_name, params = {})
42
+ Ask::TokenUsage.estimate(activity_name, params)
43
+ end
44
+
45
+ # True when the balance covers the estimated cost of an activity.
46
+ def enough_for?(activity_name, params = {})
47
+ has?(estimate(activity_name, params))
48
+ end
49
+
50
+ # Total tokens debited since +time+ (absolute value). Useful for
51
+ # monthly-usage reports.
52
+ def used_since(time)
53
+ entries(kind: :debit, since: time).sum(&:amount).abs
54
+ end
55
+
56
+ # ---- Mutations -------------------------------------------------------
57
+
58
+ # Add +amount+ tokens. +expires_at+ marks the grant as expiring.
59
+ # Returns the written LedgerEntry.
60
+ def grant!(amount, reason:, expires_at: nil, metadata: {})
61
+ amount = amount.to_i
62
+ raise ArgumentError, "grant amount must be positive (got #{amount})" unless amount.positive?
63
+
64
+ mutate(:grant, amount, reason, expires_at: expires_at, metadata: metadata) do |current|
65
+ previous = current
66
+ [previous + amount, previous, amount]
67
+ end
68
+ end
69
+
70
+ # Deduct +amount+ tokens, raising InsufficientTokens when the balance
71
+ # is too low (unless negatives are allowed). With a block, the block
72
+ # runs first and tokens are charged only when it succeeds.
73
+ def deduct!(amount, reason:, metadata: {}, &block)
74
+ amount = amount.to_i
75
+ raise ArgumentError, "deduct amount must be positive (got #{amount})" unless amount.positive?
76
+
77
+ if block
78
+ spend_after_success(amount, reason, metadata, &block)
79
+ else
80
+ charge(amount, reason, metadata)
81
+ end
82
+ end
83
+
84
+ # Spend the estimated token cost of +activity+ given +params+. With a
85
+ # block, charge only if the block succeeds — the standard wrapper for
86
+ # LLM calls and render jobs.
87
+ def spend!(activity_name, params = {}, &block)
88
+ amount = estimate(activity_name, params)
89
+ deduct!(amount, reason: activity_name.to_s, metadata: params.dup, &block)
90
+ end
91
+
92
+ # Deduct an exact token cost (same as deduct!, but stays at wallet
93
+ # level for callers that prefer namespacing).
94
+ def spend_amount!(amount, reason:, metadata: {}, &block)
95
+ deduct!(amount, reason: reason, metadata: metadata, &block)
96
+ end
97
+
98
+ # Set the balance to an exact amount, recording the delta as an
99
+ # adjustment entry. Used by billing systems to reset monthly
100
+ # allowances (replace remaining balance) or fix incorrect totals.
101
+ #
102
+ # wallet.adjust_balance_to!(10_000, reason: :monthly_reset)
103
+ #
104
+ def adjust_balance_to!(amount, reason:, metadata: {})
105
+ amount = amount.to_i
106
+ raise ArgumentError, "balance cannot be negative (got #{amount})" if amount.negative? && !Ask::TokenUsage.config.negatives
107
+
108
+ @store.with_lock(owner) do
109
+ previous = @store.balance(owner).to_i
110
+ delta = amount - previous
111
+ return nil if delta.zero?
112
+
113
+ entry = record(:adjustment, delta, reason.to_s, amount, expires_at: nil, metadata: metadata)
114
+ dispatch(:on_adjust, :adjustment, delta, reason, metadata, entry, previous, amount)
115
+ entry
116
+ end
117
+ end
118
+
119
+ private
120
+
121
+ def charge(amount, reason, metadata)
122
+ @store.with_lock(owner) do
123
+ previous = @store.balance(owner).to_i
124
+ ensure_sufficient!(amount, previous)
125
+
126
+ new_balance = previous - amount
127
+ entry = record(:debit, -amount, reason, new_balance, expires_at: nil, metadata: metadata)
128
+ dispatch(:on_spend, :spend, -amount, reason, metadata, entry, previous, new_balance)
129
+ entry
130
+ end
131
+ end
132
+
133
+ # Run the work first, then charge only on success. The pre-check is a
134
+ # cheap fast path; the authoritative check happens again under the
135
+ # store lock in #charge, so two concurrent spends can never overspend
136
+ # even though neither holds the lock while their block runs.
137
+ def spend_after_success(amount, reason, metadata)
138
+ ensure_sufficient!(amount, @store.balance(owner).to_i)
139
+ result = yield
140
+ charge(amount, reason, metadata)
141
+ result
142
+ end
143
+
144
+ def mutate(kind, signed_amount, reason, expires_at:, metadata:)
145
+ @store.with_lock(owner) do
146
+ previous = @store.balance(owner).to_i
147
+ new_balance, _, _ = yield(previous)
148
+
149
+ entry = record(kind, signed_amount, reason, new_balance, expires_at: expires_at, metadata: metadata)
150
+ dispatch(:on_grant, kind, signed_amount, reason, metadata, entry, previous, new_balance) if kind == :grant
151
+ entry
152
+ end
153
+ end
154
+
155
+ def record(kind, signed_amount, reason, new_balance, expires_at:, metadata: {})
156
+ entry = LedgerEntry.new(
157
+ id: nil,
158
+ kind: kind,
159
+ amount: signed_amount,
160
+ reason: reason.to_s,
161
+ expires_at: expires_at,
162
+ metadata: metadata,
163
+ balance: new_balance,
164
+ created_at: @time.call
165
+ )
166
+ persisted = @store.append(owner, entry)
167
+ @store.write_balance(owner, new_balance)
168
+ persisted
169
+ end
170
+
171
+ def ensure_sufficient!(amount, available)
172
+ return if Ask::TokenUsage.config.negatives
173
+
174
+ return if available >= amount
175
+
176
+ dispatch(:on_insufficient, :insufficient, -amount, nil, {}, nil, available, available)
177
+ raise InsufficientTokens.new(required: amount, available: available)
178
+ end
179
+
180
+ def dispatch(event, event_name, signed_amount, reason, metadata, entry, previous, new_balance)
181
+ hook = Ask::TokenUsage.config.public_send(event)
182
+ return unless hook
183
+
184
+ context = CallbackContext.new(
185
+ owner: owner,
186
+ event: event_name,
187
+ amount: signed_amount,
188
+ reason: reason,
189
+ metadata: metadata,
190
+ entry: entry,
191
+ previous_balance: previous,
192
+ new_balance: new_balance
193
+ )
194
+ hook.call(context)
195
+ rescue StandardError => e
196
+ warn "ask-token-usage: #{event} callback raised #{e.class}: #{e.message}"
197
+ end
198
+ end
199
+ end
200
+ end
@@ -0,0 +1,115 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "money"
4
+ require "date"
5
+
6
+ Money.default_infinite_precision = true
7
+
8
+ require_relative "token_usage/version"
9
+ require_relative "token_usage/configuration"
10
+ require_relative "token_usage/insufficient_tokens"
11
+ require_relative "token_usage/ledger_entry"
12
+ require_relative "token_usage/callback_context"
13
+ require_relative "token_usage/activity"
14
+ require_relative "token_usage/activity_registry"
15
+ require_relative "token_usage/stores/memory"
16
+ require_relative "token_usage/pricing"
17
+ require_relative "token_usage/counting"
18
+ require_relative "token_usage/wallet"
19
+
20
+ module Ask
21
+ # Token accounting for anything you sell by measured tokens.
22
+ #
23
+ # The gem is Railless by design: it counts real tokens (tiktoken), prices
24
+ # them against a per-million rate (money), and runs a wallet/ledger engine
25
+ # on top of a pluggable store. Persistence is the host's job — ship the
26
+ # in-memory store for scripts/tests, or hand in an ActiveRecord-backed
27
+ # store (ask-token-usage-rails) inside Rails.
28
+ #
29
+ # Ask::TokenUsage.configure do |c|
30
+ # c.price_per_1m = Money.from_amount(100, "USD")
31
+ # end
32
+ #
33
+ # Ask::TokenUsage.activity(:chat_message) do |params|
34
+ # Ask::TokenUsage.count_tokens(params[:input]) +
35
+ # Ask::TokenUsage.count_tokens(params[:output])
36
+ # end
37
+ #
38
+ # wallet = Ask::TokenUsage.wallet_for("user:42")
39
+ # wallet.grant!(1000, reason: :signup)
40
+ # wallet.spend!(:chat_message, input: "hi", output: "yo") do
41
+ # LLM.chat(...) # only charged when the block succeeds
42
+ # end
43
+ # wallet.balance # => 996
44
+ #
45
+ module TokenUsage
46
+ class << self
47
+ # Global configuration.
48
+ def config
49
+ @config ||= Configuration.new
50
+ end
51
+
52
+ def configure
53
+ yield config
54
+ end
55
+
56
+ # Count the tokens in +text+ using tiktoken. Pass +model:+ for a
57
+ # model-aware encoding; unknown models fall back to cl100k_base.
58
+ def count_tokens(text, model: nil)
59
+ Counting.count(text, model: model)
60
+ end
61
+
62
+ # Price of +count+ tokens at the configured per-token rate.
63
+ #
64
+ # Ask::TokenUsage.price_per(1_000) # => Money($0.10)
65
+ # Ask::TokenUsage.price_per(1_000_000) # => Money($100.00)
66
+ #
67
+ def price_per(count)
68
+ Pricing.price_of(count)
69
+ end
70
+
71
+ # The configured Money value of one billing token.
72
+ def price_per_token
73
+ config.price_per_token
74
+ end
75
+
76
+ # How many billing tokens a Money amount buys at the configured rate.
77
+ def tokens_for(money)
78
+ Pricing.tokens_for(money)
79
+ end
80
+
81
+ # The price of +count+ tokens expressed in cents.
82
+ def cents_for(count, currency: nil)
83
+ Pricing.cents_for(count, currency: currency)
84
+ end
85
+
86
+ # Register an activity. +cost:+ is a fixed integer; without it the block
87
+ # receives params and must return an integer token count.
88
+ def activity(name, cost: nil, &block)
89
+ activities.register(name, cost: cost, &block)
90
+ end
91
+
92
+ # The estimated token cost of running +activity+ with +params+, without
93
+ # spending anything.
94
+ def estimate(activity_name, params = {})
95
+ activities.fetch(activity_name).cost(params)
96
+ end
97
+
98
+ def activities
99
+ @activities ||= ActivityRegistry.new
100
+ end
101
+
102
+ # Build a wallet for +owner+. +owner+ is an opaque identifier the store
103
+ # keys on.
104
+ def wallet_for(owner)
105
+ Wallet.new(owner: owner, store: config.store, time: config.time)
106
+ end
107
+
108
+ # Reset configuration and activity registry (mainly for tests).
109
+ def reset!
110
+ @config = Configuration.new
111
+ @activities = ActivityRegistry.new
112
+ end
113
+ end
114
+ end
115
+ end
@@ -0,0 +1,3 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "ask/token_usage"
metadata ADDED
@@ -0,0 +1,119 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: ask-token-usage
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Kaka Ruto
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: money
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: '6.13'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - ">="
24
+ - !ruby/object:Gem::Version
25
+ version: '6.13'
26
+ - !ruby/object:Gem::Dependency
27
+ name: tiktoken_ruby
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - ">="
31
+ - !ruby/object:Gem::Version
32
+ version: 0.0.5
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - ">="
38
+ - !ruby/object:Gem::Version
39
+ version: 0.0.5
40
+ - !ruby/object:Gem::Dependency
41
+ name: minitest
42
+ requirement: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - "~>"
45
+ - !ruby/object:Gem::Version
46
+ version: '5.25'
47
+ type: :development
48
+ prerelease: false
49
+ version_requirements: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - "~>"
52
+ - !ruby/object:Gem::Version
53
+ version: '5.25'
54
+ - !ruby/object:Gem::Dependency
55
+ name: rake
56
+ requirement: !ruby/object:Gem::Requirement
57
+ requirements:
58
+ - - "~>"
59
+ - !ruby/object:Gem::Version
60
+ version: '13.0'
61
+ type: :development
62
+ prerelease: false
63
+ version_requirements: !ruby/object:Gem::Requirement
64
+ requirements:
65
+ - - "~>"
66
+ - !ruby/object:Gem::Version
67
+ version: '13.0'
68
+ description: A Rails-free foundation for token-based usage tracking. Count real tokens
69
+ with tiktoken, price them per million, declare activity costs, and run a wallet/ledger
70
+ engine against a pluggable store. Pair with ask-token-usage-rails for ActiveRecord
71
+ persistence.
72
+ email:
73
+ - kaka@myrrlabs.com
74
+ executables: []
75
+ extensions: []
76
+ extra_rdoc_files: []
77
+ files:
78
+ - CHANGELOG.md
79
+ - LICENSE
80
+ - lib/ask-token-usage.rb
81
+ - lib/ask/token_usage.rb
82
+ - lib/ask/token_usage/activity.rb
83
+ - lib/ask/token_usage/activity_registry.rb
84
+ - lib/ask/token_usage/callback_context.rb
85
+ - lib/ask/token_usage/configuration.rb
86
+ - lib/ask/token_usage/counting.rb
87
+ - lib/ask/token_usage/instrumentation.rb
88
+ - lib/ask/token_usage/insufficient_tokens.rb
89
+ - lib/ask/token_usage/ledger_entry.rb
90
+ - lib/ask/token_usage/pricing.rb
91
+ - lib/ask/token_usage/stores/memory.rb
92
+ - lib/ask/token_usage/stores/store.rb
93
+ - lib/ask/token_usage/version.rb
94
+ - lib/ask/token_usage/wallet.rb
95
+ homepage: https://github.com/ask-rb/ask-token-usage
96
+ licenses:
97
+ - MIT
98
+ metadata:
99
+ homepage_uri: https://github.com/ask-rb/ask-token-usage
100
+ source_code_uri: https://github.com/ask-rb/ask-token-usage
101
+ changelog_uri: https://github.com/ask-rb/ask-token-usage/blob/main/CHANGELOG.md
102
+ rdoc_options: []
103
+ require_paths:
104
+ - lib
105
+ required_ruby_version: !ruby/object:Gem::Requirement
106
+ requirements:
107
+ - - ">="
108
+ - !ruby/object:Gem::Version
109
+ version: '3.2'
110
+ required_rubygems_version: !ruby/object:Gem::Requirement
111
+ requirements:
112
+ - - ">="
113
+ - !ruby/object:Gem::Version
114
+ version: '0'
115
+ requirements: []
116
+ rubygems_version: 4.0.18
117
+ specification_version: 4
118
+ summary: 'Pure-Ruby token accounting: count, price, and track token usage'
119
+ test_files: []