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 +7 -0
- data/CHANGELOG.md +16 -0
- data/LICENSE +21 -0
- data/lib/ask/token_usage/activity.rb +47 -0
- data/lib/ask/token_usage/activity_registry.rb +35 -0
- data/lib/ask/token_usage/callback_context.rb +23 -0
- data/lib/ask/token_usage/configuration.rb +114 -0
- data/lib/ask/token_usage/counting.rb +39 -0
- data/lib/ask/token_usage/instrumentation.rb +83 -0
- data/lib/ask/token_usage/insufficient_tokens.rb +18 -0
- data/lib/ask/token_usage/ledger_entry.rb +43 -0
- data/lib/ask/token_usage/pricing.rb +40 -0
- data/lib/ask/token_usage/stores/memory.rb +52 -0
- data/lib/ask/token_usage/stores/store.rb +44 -0
- data/lib/ask/token_usage/version.rb +7 -0
- data/lib/ask/token_usage/wallet.rb +200 -0
- data/lib/ask/token_usage.rb +115 -0
- data/lib/ask-token-usage.rb +3 -0
- metadata +119 -0
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,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
|
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: []
|