pwn 0.5.749 → 0.5.751
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/Gemfile +2 -2
- data/documentation/Blockchain.md +107 -9
- data/documentation/pwn-ai-Agent.md +310 -4
- data/documentation/pwn_silent_help_learn_demo.gif +0 -0
- data/etc/default_skills/pwn/banner/SKILL.md +7 -3
- data/etc/default_skills/pwn/blockchain/btc/SKILL.md +8 -5
- data/etc/default_skills/pwn/blockchain/eth/SKILL.md +12 -3
- data/etc/default_skills/pwn/blockchain/eth/references/urls.md +1 -1
- data/etc/default_skills/pwn/plugins/repl/SKILL.md +5 -0
- data/etc/default_skills/pwn/plugins/repl/ai/SKILL.md +4 -0
- data/etc/default_skills/pwn/plugins/repl/ai/references/urls.md +5 -0
- data/etc/default_skills/pwn/plugins/repl/ai_console/SKILL.md +48 -0
- data/etc/default_skills/pwn/plugins/repl/ai_console_commands/SKILL.md +47 -0
- data/etc/default_skills/pwn/plugins/repl/ai_console_usage/SKILL.md +48 -0
- data/etc/default_skills/pwn/plugins/repl/ai_swarm/SKILL.md +46 -0
- data/lib/pwn/ai/agent/loop.rb +287 -18
- data/lib/pwn/ai/agent/swarm.rb +28 -4
- data/lib/pwn/ai/open_ai.rb +4 -2
- data/lib/pwn/banner.rb +743 -2
- data/lib/pwn/blockchain/btc.rb +494 -196
- data/lib/pwn/blockchain/eth.rb +440 -81
- data/lib/pwn/blockchain.rb +6 -0
- data/lib/pwn/config.rb +22 -0
- data/lib/pwn/migrate.rb +9 -1
- data/lib/pwn/plugins/repl/ai.rb +182 -17
- data/lib/pwn/plugins/repl/ai_console.rb +1581 -0
- data/lib/pwn/plugins/repl/ai_console_commands.rb +282 -0
- data/lib/pwn/plugins/repl/ai_console_usage.rb +144 -0
- data/lib/pwn/plugins/repl/ai_swarm.rb +375 -0
- data/lib/pwn/plugins/repl.rb +25 -3
- data/lib/pwn/version.rb +1 -1
- data/spec/lib/pwn/ai/agent/loop_spec.rb +258 -1
- data/spec/lib/pwn/ai/agent/turn_finalizer_spec.rb +3 -2
- data/spec/lib/pwn/ai/open_ai_spec.rb +5 -0
- data/spec/lib/pwn/banner_spec.rb +643 -0
- data/spec/lib/pwn/blockchain/btc_spec.rb +372 -1
- data/spec/lib/pwn/blockchain/eth_spec.rb +205 -0
- data/spec/lib/pwn/blockchain_spec.rb +6 -0
- data/spec/lib/pwn/config_spec.rb +10 -0
- data/spec/lib/pwn/migrate_spec.rb +40 -3
- data/spec/lib/pwn/plugins/repl/ai_console_commands_spec.rb +65 -0
- data/spec/lib/pwn/plugins/repl/ai_console_spec.rb +1434 -0
- data/spec/lib/pwn/plugins/repl/ai_console_usage_spec.rb +29 -0
- data/spec/lib/pwn/plugins/repl/ai_swarm_spec.rb +127 -0
- data/spec/lib/pwn/plugins/repl_pwn_vault_spec.rb +5 -3
- data/spec/lib/pwn/plugins/repl_spec.rb +167 -1
- data/third_party/pwn_rdoc.jsonl +229 -10
- metadata +18 -6
- data/etc/default_skills/pwn/blockchain/btc/references/urls.md +0 -3
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PWN
|
|
4
|
+
module Plugins
|
|
5
|
+
module REPL
|
|
6
|
+
# Live slash-command parameter completion. Never calls a provider.
|
|
7
|
+
module AIConsoleCommands
|
|
8
|
+
COMMANDS = %w[
|
|
9
|
+
/back /clear /cron /debug /delegate /help /input /learning /mcp /memory /menu /model /system-role
|
|
10
|
+
/sessions /skills /status /steer /swarm /trace /verbose
|
|
11
|
+
].freeze
|
|
12
|
+
SWARM = %w[dashboard help roster status create use spawn retire ask debate broadcast tail steer cancel].freeze
|
|
13
|
+
|
|
14
|
+
public_class_method def self.complete(opts = {})
|
|
15
|
+
line = opts[:line].to_s
|
|
16
|
+
cursor = opts[:cursor].to_i
|
|
17
|
+
tokens = scan(line: line, cursor: cursor)
|
|
18
|
+
current = tokens.find { |token| token[:current] } || tokens.last || { text: '', start: 0, finish: 0 }
|
|
19
|
+
prefix = line.scan(/\X/)[current[:start]...cursor].to_a.join
|
|
20
|
+
values, hint = if prefix.start_with?('PWN')
|
|
21
|
+
pwn_constants(prefix: prefix)
|
|
22
|
+
elsif path_token?(prefix: prefix)
|
|
23
|
+
paths(prefix: prefix)
|
|
24
|
+
else
|
|
25
|
+
candidates(tokens: tokens, prefix: prefix, pry: opts[:pry], swarm: opts[:swarm])
|
|
26
|
+
end
|
|
27
|
+
items = values.map do |value|
|
|
28
|
+
replacement = line_replace(line: line, token: current, value: value)
|
|
29
|
+
{ label: value, text: replacement[:text], cursor: replacement[:cursor] }
|
|
30
|
+
end
|
|
31
|
+
{ items: items, hint: hint }
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
public_class_method def self.authors
|
|
35
|
+
'AUTHOR(S): 0day Inc. <support@0dayinc.com>'
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
public_class_method def self.help
|
|
39
|
+
puts "USAGE:
|
|
40
|
+
# Complete the token at a grapheme cursor. Returned text replaces the whole line.
|
|
41
|
+
#{self}.complete(
|
|
42
|
+
line: 'required - current mission text',
|
|
43
|
+
cursor: 'optional - grapheme index, default 0',
|
|
44
|
+
pry: 'optional - Pry instance for Ruby completion',
|
|
45
|
+
swarm: 'optional - controller exposing completion_context'
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
# Return module authors.
|
|
49
|
+
#{self}.authors
|
|
50
|
+
"
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
private_class_method def self.path_token?(opts = {})
|
|
54
|
+
prefix = opts[:prefix].to_s
|
|
55
|
+
return true if prefix.match?(%r{\A(?:~|\./|\.\./)})
|
|
56
|
+
return false unless prefix.start_with?('/')
|
|
57
|
+
return false if COMMANDS.any? { |cmd| cmd.start_with?(prefix) || prefix == cmd }
|
|
58
|
+
|
|
59
|
+
prefix.count('/') > 1 || File.directory?(prefix)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
private_class_method def self.pwn_constants(opts = {})
|
|
63
|
+
prefix = opts[:prefix].to_s
|
|
64
|
+
parts = prefix.split('::', -1)
|
|
65
|
+
mod = Object
|
|
66
|
+
parts[0..-2].each do |name|
|
|
67
|
+
return [[], 'PWN constant'] unless mod.respond_to?(:const_defined?) && mod.const_defined?(name, false)
|
|
68
|
+
|
|
69
|
+
mod = mod.const_get(name, false)
|
|
70
|
+
end
|
|
71
|
+
return [[], 'PWN constant'] unless mod.is_a?(Module)
|
|
72
|
+
|
|
73
|
+
stem = parts.last.to_s
|
|
74
|
+
parent = parts[0..-2].reject(&:empty?).join('::')
|
|
75
|
+
names = mod.constants(false).sort.select { |name| name.to_s.start_with?(stem) }.filter_map do |name|
|
|
76
|
+
shown = parent.empty? ? name.to_s : "#{parent}::#{name}"
|
|
77
|
+
shown += '::' if mod.autoload?(name) || mod.const_get(name, false).is_a?(Module)
|
|
78
|
+
shown
|
|
79
|
+
rescue StandardError
|
|
80
|
+
nil
|
|
81
|
+
end
|
|
82
|
+
[names, 'PWN constant']
|
|
83
|
+
rescue NameError, LoadError
|
|
84
|
+
[[], 'PWN constant unavailable']
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
private_class_method def self.paths(opts = {})
|
|
88
|
+
prefix = opts[:prefix].to_s
|
|
89
|
+
[REPL.pwn_ai_complete_path(target: prefix, line: prefix).first(12), 'path']
|
|
90
|
+
rescue StandardError
|
|
91
|
+
[[], 'path']
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
private_class_method def self.scan(opts = {})
|
|
95
|
+
graphemes = opts[:line].scan(/\X/)
|
|
96
|
+
cursor = opts[:cursor].clamp(0, graphemes.length)
|
|
97
|
+
tokens = []
|
|
98
|
+
index = 0
|
|
99
|
+
while index < graphemes.length
|
|
100
|
+
index += 1 while index < graphemes.length && graphemes[index].match?(/\s/)
|
|
101
|
+
break if index >= graphemes.length
|
|
102
|
+
|
|
103
|
+
start = index
|
|
104
|
+
quote = nil
|
|
105
|
+
while index < graphemes.length
|
|
106
|
+
char = graphemes[index]
|
|
107
|
+
if quote
|
|
108
|
+
quote = nil if char == quote
|
|
109
|
+
elsif %w[' "].include?(char)
|
|
110
|
+
quote = char
|
|
111
|
+
elsif char.match?(/\s/)
|
|
112
|
+
break
|
|
113
|
+
end
|
|
114
|
+
index += 1
|
|
115
|
+
end
|
|
116
|
+
tokens << { text: graphemes[start...index].join, start: start, finish: index, current: cursor.between?(start, index) }
|
|
117
|
+
end
|
|
118
|
+
tokens << { text: '', start: cursor, finish: cursor, current: true } if tokens.none? { |token| token[:current] }
|
|
119
|
+
tokens
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
private_class_method def self.line_replace(opts = {})
|
|
123
|
+
graphemes = opts[:line].scan(/\X/)
|
|
124
|
+
token = opts[:token]
|
|
125
|
+
value = opts[:value].to_s
|
|
126
|
+
graphemes[token[:start]...token[:finish]] = value.scan(/\X/)
|
|
127
|
+
{ text: graphemes.join, cursor: token[:start] + value.scan(/\X/).length }
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
private_class_method def self.candidates(opts = {})
|
|
131
|
+
tokens = opts[:tokens]
|
|
132
|
+
prefix = opts[:prefix]
|
|
133
|
+
command = tokens.first[:text]
|
|
134
|
+
position = tokens.index { |token| token[:current] }.to_i
|
|
135
|
+
return [filter(values: COMMANDS, prefix: prefix), 'command'] if position.zero?
|
|
136
|
+
|
|
137
|
+
case command
|
|
138
|
+
when '/verbose' then [filter(values: %w[on off], prefix: prefix), 'verbosity']
|
|
139
|
+
when '/steer', '/input', '/delegate' then [[], 'free text; no invented values']
|
|
140
|
+
when '/cron' then cron(tokens: tokens, position: position, prefix: prefix)
|
|
141
|
+
when '/memory' then memory(tokens: tokens, position: position, prefix: prefix)
|
|
142
|
+
when '/sessions' then sessions(tokens: tokens, position: position, prefix: prefix)
|
|
143
|
+
when '/skills' then skills(tokens: tokens, position: position, prefix: prefix)
|
|
144
|
+
when '/learning' then learning(tokens: tokens, position: position, prefix: prefix)
|
|
145
|
+
when '/model' then model(tokens: tokens, position: position, prefix: prefix)
|
|
146
|
+
when '/mcp' then mcp(tokens: tokens, position: position, prefix: prefix)
|
|
147
|
+
when '/swarm' then swarm(tokens: tokens, position: position, prefix: prefix, controller: opts[:swarm])
|
|
148
|
+
else [[], 'no further parameters']
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
private_class_method def self.filter(opts = {})
|
|
153
|
+
prefix = opts[:prefix].to_s
|
|
154
|
+
Array(opts[:values]).map(&:to_s).uniq.select { |value| prefix.empty? || value.start_with?(prefix) }
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
private_class_method def self.cron(opts = {})
|
|
158
|
+
tokens = opts[:tokens]
|
|
159
|
+
position = opts[:position]
|
|
160
|
+
prefix = opts[:prefix]
|
|
161
|
+
return [filter(values: %w[list create run remove], prefix: prefix), 'cron action'] if position == 1
|
|
162
|
+
return [[], 'cron schedule, for example 0 * * * *'] if tokens[1][:text] == 'create' && position == 2
|
|
163
|
+
return [[], 'prompt text'] if tokens[1][:text] == 'create'
|
|
164
|
+
|
|
165
|
+
[filter(values: safe { PWN::Cron.list.keys }, prefix: prefix), 'job id']
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
private_class_method def self.memory(opts = {})
|
|
169
|
+
tokens = opts[:tokens]
|
|
170
|
+
position = opts[:position]
|
|
171
|
+
prefix = opts[:prefix]
|
|
172
|
+
return [filter(values: %w[list recall remember forget clear], prefix: prefix), 'memory action'] if position == 1
|
|
173
|
+
return [[], 'memory value'] if tokens[1][:text] == 'remember' && position > 2
|
|
174
|
+
|
|
175
|
+
[filter(values: safe { PWN::Memory.load.keys }, prefix: prefix), 'memory key']
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
private_class_method def self.sessions(opts = {})
|
|
179
|
+
tokens = opts[:tokens]
|
|
180
|
+
position = opts[:position]
|
|
181
|
+
prefix = opts[:prefix]
|
|
182
|
+
return [filter(values: %w[list resume delete stats], prefix: prefix), 'session action'] if position == 1
|
|
183
|
+
return [[], 'no further parameters'] unless %w[resume delete].include?(tokens[1][:text])
|
|
184
|
+
|
|
185
|
+
ids = safe { PWN::Sessions.list.map { |row| row[:id] || row['id'] } }
|
|
186
|
+
[filter(values: ids, prefix: prefix), 'session id']
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
private_class_method def self.skills(opts = {})
|
|
190
|
+
position = opts[:position]
|
|
191
|
+
prefix = opts[:prefix]
|
|
192
|
+
return [filter(values: %w[list recall], prefix: prefix), 'skills action'] if position == 1
|
|
193
|
+
|
|
194
|
+
names = safe { PWN::Skills.keys } if PWN.const_defined?(:Skills)
|
|
195
|
+
[filter(values: names, prefix: prefix), 'skill name']
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
private_class_method def self.learning(opts = {})
|
|
199
|
+
tokens = opts[:tokens]
|
|
200
|
+
position = opts[:position]
|
|
201
|
+
prefix = opts[:prefix]
|
|
202
|
+
return [filter(values: %w[list requeue], prefix: prefix), 'learning action'] if position == 1
|
|
203
|
+
return [filter(values: %w[--conflicted conflicted], prefix: prefix), 'list filter'] if tokens[1][:text] == 'list'
|
|
204
|
+
|
|
205
|
+
[[], 'no further parameters']
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
private_class_method def self.model(opts = {})
|
|
209
|
+
tokens = opts[:tokens]
|
|
210
|
+
position = opts[:position]
|
|
211
|
+
prefix = opts[:prefix]
|
|
212
|
+
engines = safe { REPL.pwn_ai_engines }
|
|
213
|
+
return [filter(values: ['list'] + engines, prefix: prefix), 'engine or list'] if position == 1
|
|
214
|
+
return [filter(values: %w[llms], prefix: prefix), 'remote catalog; not fetched while typing'] if tokens[1][:text] == 'list'
|
|
215
|
+
return [[], 'model id; supported models prompt for effort after Enter (no catalog while typing)'] if position >= 2
|
|
216
|
+
|
|
217
|
+
current = safe { [REPL.pwn_ai_engine_model(engine: tokens[1][:text])] }
|
|
218
|
+
[filter(values: current, prefix: prefix), 'configured model']
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
private_class_method def self.mcp(opts = {})
|
|
222
|
+
tokens = opts[:tokens]
|
|
223
|
+
position = opts[:position]
|
|
224
|
+
prefix = opts[:prefix]
|
|
225
|
+
actions = %w[list backends use current connect disconnect ping tools call status help]
|
|
226
|
+
backends = safe { PWN::AI::MCP.backends.map { |row| row[:name] } }
|
|
227
|
+
return [filter(values: actions + backends, prefix: prefix), 'mcp action or backend'] if position == 1
|
|
228
|
+
return [filter(values: backends, prefix: prefix), 'backend'] if %w[use connect disconnect].include?(tokens[1][:text])
|
|
229
|
+
return [filter(values: tool_names(backends: backends), prefix: prefix), 'tool name'] if tokens[1][:text] == 'call'
|
|
230
|
+
|
|
231
|
+
[[], 'mcp argument']
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
private_class_method def self.tool_names(opts = {})
|
|
235
|
+
Array(opts[:backends]).flat_map do |name|
|
|
236
|
+
row = PWN::AI::MCP.backends.find { |backend| backend[:name].to_s == name.to_s }
|
|
237
|
+
Array(row && row[:tools])
|
|
238
|
+
end
|
|
239
|
+
rescue StandardError
|
|
240
|
+
[]
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
private_class_method def self.swarm(opts = {})
|
|
244
|
+
tokens = opts[:tokens]
|
|
245
|
+
position = opts[:position]
|
|
246
|
+
prefix = opts[:prefix]
|
|
247
|
+
controller = opts[:controller]
|
|
248
|
+
return [filter(values: SWARM, prefix: prefix), 'swarm command'] if position == 1
|
|
249
|
+
|
|
250
|
+
context = controller.respond_to?(:completion_context) ? controller.completion_context : {}
|
|
251
|
+
action = tokens[1][:text]
|
|
252
|
+
jobs = Array(context[:jobs]).map { |job| job[:id] || job['id'] }
|
|
253
|
+
agents = Array(context[:agents])
|
|
254
|
+
case action
|
|
255
|
+
when 'status', 'steer', 'cancel' then [filter(values: jobs + (action == 'cancel' ? ['all'] : []), prefix: prefix), 'job id']
|
|
256
|
+
when 'use' then [filter(values: existing_swarms, prefix: prefix), 'swarm id']
|
|
257
|
+
when 'spawn' then [[], position == 2 ? 'persona name' : 'role text']
|
|
258
|
+
when 'retire', 'ask' then [filter(values: agents, prefix: prefix), 'persona']
|
|
259
|
+
when 'debate' then [filter(values: agents, prefix: prefix), 'comma-separated personas, then topic']
|
|
260
|
+
when 'tail' then [[], 'message limit']
|
|
261
|
+
else [[], 'free text']
|
|
262
|
+
end
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
private_class_method def self.existing_swarms
|
|
266
|
+
root = PWN::AI::Agent::Swarm::SWARM_ROOT
|
|
267
|
+
return [] unless Dir.exist?(root)
|
|
268
|
+
|
|
269
|
+
Dir.children(root)
|
|
270
|
+
rescue StandardError
|
|
271
|
+
[]
|
|
272
|
+
end
|
|
273
|
+
|
|
274
|
+
private_class_method def self.safe
|
|
275
|
+
Array(yield).map(&:to_s)
|
|
276
|
+
rescue StandardError
|
|
277
|
+
[]
|
|
278
|
+
end
|
|
279
|
+
end
|
|
280
|
+
end
|
|
281
|
+
end
|
|
282
|
+
end
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PWN
|
|
4
|
+
module Plugins
|
|
5
|
+
module REPL
|
|
6
|
+
# Provider-reported token totals and explicitly configured USD estimates.
|
|
7
|
+
module AIConsoleUsage
|
|
8
|
+
# Accumulate one observer event per provider completion.
|
|
9
|
+
class Tracker
|
|
10
|
+
def initialize
|
|
11
|
+
@lock = Mutex.new
|
|
12
|
+
@input = 0
|
|
13
|
+
@output = 0
|
|
14
|
+
@cached = 0
|
|
15
|
+
@cache_write = 0
|
|
16
|
+
@calls = 0
|
|
17
|
+
@latest = 0
|
|
18
|
+
@priced = 0.0
|
|
19
|
+
@priced_calls = 0
|
|
20
|
+
@unpriced_calls = 0
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def record(opts = {})
|
|
24
|
+
usage = AIConsoleUsage.normalize(opts)
|
|
25
|
+
price = AIConsoleUsage.estimate(opts.merge(usage: usage))
|
|
26
|
+
@lock.synchronize do
|
|
27
|
+
@input += usage[:input_tokens]
|
|
28
|
+
@output += usage[:output_tokens]
|
|
29
|
+
@cached += usage[:cached_tokens]
|
|
30
|
+
@cache_write += usage[:cache_write_tokens]
|
|
31
|
+
@calls += 1
|
|
32
|
+
@latest = usage[:total_tokens]
|
|
33
|
+
if price
|
|
34
|
+
@priced += price
|
|
35
|
+
@priced_calls += 1
|
|
36
|
+
else
|
|
37
|
+
@unpriced_calls += 1
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
snapshot
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def snapshot
|
|
44
|
+
@lock.synchronize do
|
|
45
|
+
status = if @calls.zero?
|
|
46
|
+
'no provider usage yet'
|
|
47
|
+
elsif @unpriced_calls.zero?
|
|
48
|
+
'estimated from configured USD rates'
|
|
49
|
+
elsif @priced_calls.zero?
|
|
50
|
+
'unavailable: no configured USD rate'
|
|
51
|
+
else
|
|
52
|
+
"partial: #{@unpriced_calls} call(s) have no configured rate"
|
|
53
|
+
end
|
|
54
|
+
{
|
|
55
|
+
input_tokens: @input, output_tokens: @output, cached_tokens: @cached,
|
|
56
|
+
cache_write_tokens: @cache_write, total_tokens: @input + @output,
|
|
57
|
+
latest_context_tokens: @latest, calls: @calls,
|
|
58
|
+
estimated_cost_usd: @priced_calls.zero? ? nil : @priced,
|
|
59
|
+
cost_status: status
|
|
60
|
+
}
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Normalize a provider usage hash without double-counting cached tokens.
|
|
66
|
+
public_class_method def self.normalize(opts = {})
|
|
67
|
+
raw = opts[:usage].is_a?(Hash) ? opts[:usage] : opts
|
|
68
|
+
details = raw[:input_tokens_details] || raw[:prompt_tokens_details] || {}
|
|
69
|
+
cached = integer(value: details[:cached_tokens] || details[:cache_read_input_tokens] || raw[:cache_read_input_tokens])
|
|
70
|
+
written = integer(value: details[:cache_creation_input_tokens] || raw[:cache_creation_input_tokens])
|
|
71
|
+
input = integer(value: raw[:input_tokens] || raw[:prompt_tokens])
|
|
72
|
+
output = integer(value: raw[:output_tokens] || raw[:completion_tokens])
|
|
73
|
+
reasoning = integer(value: (raw[:output_tokens_details] || {})[:reasoning_tokens] || raw[:reasoning_tokens])
|
|
74
|
+
output += reasoning if raw[:completion_tokens].nil? && raw[:output_tokens].nil?
|
|
75
|
+
reported = raw[:total_tokens]
|
|
76
|
+
total = reported.nil? ? input + output : integer(value: reported)
|
|
77
|
+
{ input_tokens: input, output_tokens: output, cached_tokens: cached, cache_write_tokens: written, total_tokens: total }
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Price only when every used component has an explicit finite USD rate.
|
|
81
|
+
public_class_method def self.estimate(opts = {})
|
|
82
|
+
usage = opts[:usage] || normalize(opts)
|
|
83
|
+
rates = opts[:rates] || rates_for(engine: opts[:engine], model: opts[:model])
|
|
84
|
+
return nil unless rates.is_a?(Hash)
|
|
85
|
+
|
|
86
|
+
components = [
|
|
87
|
+
[usage[:input_tokens] - usage[:cached_tokens] - usage[:cache_write_tokens], rates[:input_per_million]],
|
|
88
|
+
[usage[:cached_tokens], rates[:cache_read_per_million] || rates[:input_per_million]],
|
|
89
|
+
[usage[:cache_write_tokens], rates[:cache_write_per_million]],
|
|
90
|
+
[usage[:output_tokens], rates[:output_per_million]]
|
|
91
|
+
]
|
|
92
|
+
return nil if components.any? { |tokens, rate| tokens.to_i.positive? && !rate?(value: rate) }
|
|
93
|
+
return nil if components.any? { |tokens, _rate| tokens.to_i.negative? }
|
|
94
|
+
|
|
95
|
+
components.sum { |tokens, rate| tokens.to_i * rate.to_f / 1_000_000.0 }
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
public_class_method def self.authors
|
|
99
|
+
'AUTHOR(S): 0day Inc. <support@0dayinc.com>'
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
public_class_method def self.help
|
|
103
|
+
puts "USAGE:
|
|
104
|
+
# Normalize one provider usage report. Cached tokens are included in input, not added again.
|
|
105
|
+
#{self}.normalize(
|
|
106
|
+
usage: 'required - provider usage hash'
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
# Estimate USD only from explicit nonnegative rates; nil means unavailable, never zero.
|
|
110
|
+
#{self}.estimate(
|
|
111
|
+
usage: 'required - normalized or provider usage hash',
|
|
112
|
+
rates: 'optional - input_per_million, output_per_million, cache_read_per_million, cache_write_per_million',
|
|
113
|
+
engine: 'optional - provider used to read configured rates',
|
|
114
|
+
model: 'optional - model key under that provider pricing hash'
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
# Return module authors.
|
|
118
|
+
#{self}.authors
|
|
119
|
+
"
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
private_class_method def self.integer(opts = {})
|
|
123
|
+
opts[:value].to_i
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
private_class_method def self.rate?(opts = {})
|
|
127
|
+
value = opts[:value]
|
|
128
|
+
value.is_a?(Numeric) && value.finite? && value >= 0
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
private_class_method def self.rates_for(opts = {})
|
|
132
|
+
engine = opts[:engine].to_s.to_sym
|
|
133
|
+
model = opts[:model].to_s
|
|
134
|
+
pricing = PWN::Env.dig(:ai, engine, :pricing) if defined?(PWN::Env)
|
|
135
|
+
return nil unless pricing.is_a?(Hash)
|
|
136
|
+
|
|
137
|
+
pricing[model.to_sym] || pricing[model]
|
|
138
|
+
rescue StandardError
|
|
139
|
+
nil
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
end
|