@voxgig/sdkgen 4.2.6 → 4.2.8
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.
- package/bin/voxgig-sdkgen +1 -1
- package/dist/helpers/naming.js +5 -4
- package/dist/helpers/naming.js.map +1 -1
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/package.json +1 -1
- package/project/.sdk/model/feature/cost.aon +43 -0
- package/project/.sdk/model/feature/feature-index.aon +1 -0
- package/project/.sdk/src/cmp/js/fragment/EntityCreateOp.fragment.js +2 -1
- package/project/.sdk/src/cmp/js/fragment/EntityListOp.fragment.js +2 -1
- package/project/.sdk/src/cmp/js/fragment/EntityLoadOp.fragment.js +2 -1
- package/project/.sdk/src/cmp/js/fragment/EntityRemoveOp.fragment.js +2 -1
- package/project/.sdk/src/cmp/js/fragment/EntityUpdateOp.fragment.js +2 -1
- package/project/.sdk/src/cmp/py/ReadmeExamplesTest_py.ts +15 -5
- package/project/.sdk/src/cmp/ts/fragment/EntityCreateOp.fragment.ts +2 -1
- package/project/.sdk/src/cmp/ts/fragment/EntityListOp.fragment.ts +2 -1
- package/project/.sdk/src/cmp/ts/fragment/EntityLoadOp.fragment.ts +2 -1
- package/project/.sdk/src/cmp/ts/fragment/EntityRemoveOp.fragment.ts +2 -1
- package/project/.sdk/src/cmp/ts/fragment/EntityUpdateOp.fragment.ts +2 -1
- package/project/.sdk/tm/c/core/sdk.h +1 -0
- package/project/.sdk/tm/c/feature/cost.c +549 -0
- package/project/.sdk/tm/c/src/feature/cost/.gitkeep +0 -0
- package/project/.sdk/tm/clojure/src/feature/cost/.gitkeep +0 -0
- package/project/.sdk/tm/cpp/feature/cost.hpp +419 -0
- package/project/.sdk/tm/cpp/src/feature/cost/.gitkeep +0 -0
- package/project/.sdk/tm/csharp/feature/CostFeature.cs +518 -0
- package/project/.sdk/tm/csharp/src/feature/cost/.gitkeep +0 -0
- package/project/.sdk/tm/dart/lib/feature/cost/CostFeature.dart +446 -0
- package/project/.sdk/tm/dart/src/feature/cost/.gitkeep +0 -0
- package/project/.sdk/tm/elixir/lib/projectname/feature/cost.ex +442 -0
- package/project/.sdk/tm/elixir/src/feature/cost/.gitkeep +0 -0
- package/project/.sdk/tm/go/feature/cost_feature.go +424 -0
- package/project/.sdk/tm/go/src/feature/cost/.gitkeep +0 -0
- package/project/.sdk/tm/go-cli/src/feature/cost/.gitkeep +0 -0
- package/project/.sdk/tm/go-mcp/src/feature/cost/.gitkeep +0 -0
- package/project/.sdk/tm/java/feature/CostFeature.java +431 -0
- package/project/.sdk/tm/java/src/feature/cost/.gitkeep +0 -0
- package/project/.sdk/tm/js/src/feature/cost/CostFeature.js +413 -0
- package/project/.sdk/tm/kotlin/feature/CostFeature.kt +430 -0
- package/project/.sdk/tm/kotlin/src/feature/cost/.gitkeep +0 -0
- package/project/.sdk/tm/lean/src/feature/cost/.gitkeep +0 -0
- package/project/.sdk/tm/lua/feature/cost_feature.lua +457 -0
- package/project/.sdk/tm/lua/src/feature/cost/.gitkeep +0 -0
- package/project/.sdk/tm/ocaml/src/feature/cost/.gitkeep +0 -0
- package/project/.sdk/tm/perl/feature/cost_feature.pm +446 -0
- package/project/.sdk/tm/perl/src/feature/cost/.gitkeep +0 -0
- package/project/.sdk/tm/php/feature/CostFeature.php +439 -0
- package/project/.sdk/tm/php/feature/TestFeature.php +31 -1
- package/project/.sdk/tm/php/src/feature/cost/.gitkeep +0 -0
- package/project/.sdk/tm/py/pkg/feature/cost_feature.py +381 -0
- package/project/.sdk/tm/py/src/feature/cost/.gitkeep +0 -0
- package/project/.sdk/tm/py-data/src/feature/cost/.gitkeep +0 -0
- package/project/.sdk/tm/rb/feature/cost_feature.rb +386 -0
- package/project/.sdk/tm/rb/src/feature/cost/.gitkeep +0 -0
- package/project/.sdk/tm/rust/feature/cost.rs +467 -0
- package/project/.sdk/tm/rust/src/feature/cost/.gitkeep +0 -0
- package/project/.sdk/tm/scala/feature/CostFeature.scala +388 -0
- package/project/.sdk/tm/scala/src/feature/cost/.gitkeep +0 -0
- package/project/.sdk/tm/swift/Sources/ProjectNameSDK/feature/CostFeature.swift +425 -0
- package/project/.sdk/tm/swift/src/feature/cost/.gitkeep +0 -0
- package/project/.sdk/tm/ts/src/feature/cost/CostFeature.ts +415 -0
- package/project/.sdk/tm/zig/feature/cost.zig +457 -0
- package/project/.sdk/tm/zig/src/feature/cost/.gitkeep +0 -0
- package/project/sdkgen-package.json +2 -1
- package/src/helpers/naming.ts +5 -4
|
@@ -0,0 +1,386 @@
|
|
|
1
|
+
# ProjectName SDK cost feature
|
|
2
|
+
#
|
|
3
|
+
# Cost tracking and spend budget. Uses BOTH seams, which is the point of the
|
|
4
|
+
# feature: money is spent per HTTP ATTEMPT (a retried call is charged again,
|
|
5
|
+
# because the upstream API charges it again), but it is owed by an
|
|
6
|
+
# OPERATION. So the transport wrap prices each attempt, and PreDone
|
|
7
|
+
# attributes the running total to "<entity>.<op>" and to the caller (the
|
|
8
|
+
# per-call ctrl actor, the same actor the audit feature records).
|
|
9
|
+
#
|
|
10
|
+
# The price of an attempt comes from the first source that answers: a
|
|
11
|
+
# response header ("header" x "perUnit"), the rate table ("rates", keyed
|
|
12
|
+
# "<entity>.<op>" / "<op>" / "*"), then the flat "unit". A body figure
|
|
13
|
+
# ("path" x "perUnit", e.g. "usage.total_tokens") is read at PreDone
|
|
14
|
+
# instead, from the already-parsed result, and describes the whole call, so
|
|
15
|
+
# it REPLACES the per-attempt estimate rather than adding to it.
|
|
16
|
+
#
|
|
17
|
+
# "budget" caps total spend. With "onBudget" => "deny" a further operation
|
|
18
|
+
# is refused at PrePoint, before an endpoint is resolved and before anything
|
|
19
|
+
# reaches the network.
|
|
20
|
+
#
|
|
21
|
+
# ORDER MATTERS. Cost must sit INSIDE the cache, or a response served from
|
|
22
|
+
# cache is charged for money that was never spent. The default (map) order
|
|
23
|
+
# puts cache innermost and cost outside it, so activate them in list form
|
|
24
|
+
# with cost first.
|
|
25
|
+
|
|
26
|
+
require_relative 'base_feature'
|
|
27
|
+
|
|
28
|
+
class ProjectNameCostFeature < ProjectNameBaseFeature
|
|
29
|
+
def initialize
|
|
30
|
+
super
|
|
31
|
+
@version = "0.0.1"
|
|
32
|
+
@name = "cost"
|
|
33
|
+
# Inactive until init (feature_init only fires init when active).
|
|
34
|
+
@active = false
|
|
35
|
+
@client = nil
|
|
36
|
+
@options = {}
|
|
37
|
+
@pending = {}
|
|
38
|
+
@seq = 0
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def init(ctx, options)
|
|
42
|
+
@client = ctx.client
|
|
43
|
+
@options = options.is_a?(Hash) ? options : {}
|
|
44
|
+
@active = @options["active"] == true
|
|
45
|
+
@pending = {}
|
|
46
|
+
@seq = 0
|
|
47
|
+
|
|
48
|
+
limit = _limit
|
|
49
|
+
|
|
50
|
+
if @client.instance_variable_get(:@_cost).nil?
|
|
51
|
+
@client.instance_variable_set(:@_cost, {
|
|
52
|
+
"currency" => @options["currency"] || "USD",
|
|
53
|
+
"total" => {
|
|
54
|
+
"calls" => 0, "attempts" => 0,
|
|
55
|
+
"amount" => 0, "reported" => 0, "estimated" => 0
|
|
56
|
+
},
|
|
57
|
+
"ops" => {},
|
|
58
|
+
"actors" => {},
|
|
59
|
+
"budget" => {
|
|
60
|
+
"limit" => limit, "spent" => 0,
|
|
61
|
+
"remaining" => limit, "exceeded" => false
|
|
62
|
+
},
|
|
63
|
+
"last" => nil,
|
|
64
|
+
})
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
return unless @active
|
|
68
|
+
|
|
69
|
+
feature = self
|
|
70
|
+
utility = ctx.utility
|
|
71
|
+
inner = utility.fetcher
|
|
72
|
+
|
|
73
|
+
utility.fetcher = ->(fctx, fullurl, fetchdef) {
|
|
74
|
+
feature.charge(fctx, fullurl, fetchdef, inner)
|
|
75
|
+
}
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# The budget gate. Runs before endpoint resolution, so a refused call
|
|
79
|
+
# costs nothing at all.
|
|
80
|
+
def PrePoint(ctx)
|
|
81
|
+
return unless @active
|
|
82
|
+
|
|
83
|
+
# Mark the context as running through the pipeline, so charge knows a
|
|
84
|
+
# PreDone is coming and does not commit the spend itself.
|
|
85
|
+
entry = @pending[ctx]
|
|
86
|
+
if entry.nil?
|
|
87
|
+
entry = _new_pending
|
|
88
|
+
@pending[ctx] = entry
|
|
89
|
+
end
|
|
90
|
+
entry["piped"] = true
|
|
91
|
+
|
|
92
|
+
limit = _limit
|
|
93
|
+
return if limit <= 0
|
|
94
|
+
|
|
95
|
+
cost = @client.instance_variable_get(:@_cost)
|
|
96
|
+
return if cost.nil?
|
|
97
|
+
return if cost["total"]["amount"] < limit
|
|
98
|
+
|
|
99
|
+
cost["budget"]["exceeded"] = true
|
|
100
|
+
|
|
101
|
+
return unless "deny" == @options["onBudget"]
|
|
102
|
+
|
|
103
|
+
err = ctx.make_error("cost_budget",
|
|
104
|
+
"Cost budget of #{_numstr(limit)} #{cost['currency']} is spent " \
|
|
105
|
+
"(#{_numstr(cost['total']['amount'])} #{cost['currency']} used)")
|
|
106
|
+
|
|
107
|
+
# Short-circuit endpoint resolution; the pipeline surfaces this error.
|
|
108
|
+
ctx.out["point"] = err
|
|
109
|
+
err
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def charge(ctx, url, fetchdef, inner)
|
|
113
|
+
# A rejecting transport still costs an attempt. Without this, a run of
|
|
114
|
+
# connection-level failures under "retry" (which rescues and tries again)
|
|
115
|
+
# would be charged nothing at all, and an onBudget "deny" ceiling could
|
|
116
|
+
# never stop it.
|
|
117
|
+
threw = nil
|
|
118
|
+
begin
|
|
119
|
+
res, err = inner.call(ctx, url, fetchdef)
|
|
120
|
+
rescue StandardError => ex
|
|
121
|
+
threw = ex
|
|
122
|
+
res = nil
|
|
123
|
+
err = ex
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
amount, source = _price(ctx, res)
|
|
127
|
+
|
|
128
|
+
entry = @pending[ctx]
|
|
129
|
+
if entry.nil?
|
|
130
|
+
entry = _new_pending
|
|
131
|
+
@pending[ctx] = entry
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
entry["attempts"] += 1
|
|
135
|
+
|
|
136
|
+
# Accumulated here, committed once at PreDone. Adding each attempt to
|
|
137
|
+
# the running total and then subtracting it again when a body figure
|
|
138
|
+
# supersedes it loses precision to catastrophic cancellation.
|
|
139
|
+
#
|
|
140
|
+
# Reported and estimated are kept apart per ATTEMPT: a 503 priced from
|
|
141
|
+
# the rate table followed by a 200 carrying the cost header is part
|
|
142
|
+
# estimate, part reported, and collapsing both into the final attempt's
|
|
143
|
+
# category would corrupt the split.
|
|
144
|
+
entry["amount"] += amount
|
|
145
|
+
entry[%w[header body].include?(source) ? "reported" : "estimated"] += amount
|
|
146
|
+
entry["source"] = source
|
|
147
|
+
|
|
148
|
+
cost = @client.instance_variable_get(:@_cost)
|
|
149
|
+
cost["total"]["attempts"] += 1 unless cost.nil?
|
|
150
|
+
|
|
151
|
+
# direct() and graphql() reach the transport without dispatching any
|
|
152
|
+
# pipeline hooks, so there is no PrePoint to gate on and no PreDone to
|
|
153
|
+
# commit. Their spend is committed here, or it would never be counted.
|
|
154
|
+
# "piped" is set by PrePoint, so its absence is the signal.
|
|
155
|
+
unless entry["piped"]
|
|
156
|
+
_commit(ctx, entry, "_", "direct")
|
|
157
|
+
@pending.delete(ctx)
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
raise threw unless threw.nil?
|
|
161
|
+
|
|
162
|
+
return res, err
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def _new_pending
|
|
166
|
+
{
|
|
167
|
+
"attempts" => 0, "amount" => 0,
|
|
168
|
+
"reported" => 0, "estimated" => 0,
|
|
169
|
+
"source" => "none", "piped" => false
|
|
170
|
+
}
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
# Attribute the operation's spend once the call is finished.
|
|
174
|
+
def PreDone(ctx)
|
|
175
|
+
_finish(ctx, true)
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
# A failed operation still spent the money. When the pipeline raises,
|
|
179
|
+
# PreDone never runs, so without this the attempts are counted and the spend
|
|
180
|
+
# is not, and a budget could never see the cost of a failed call. Whichever
|
|
181
|
+
# hook fires first consumes the pending entry, so it commits exactly once.
|
|
182
|
+
def PreUnexpected(ctx)
|
|
183
|
+
_finish(ctx, false)
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
def _finish(ctx, done)
|
|
187
|
+
return unless @active
|
|
188
|
+
return unless @pending.key?(ctx)
|
|
189
|
+
entry = @pending.delete(ctx)
|
|
190
|
+
|
|
191
|
+
# A FAILED operation that made no attempt never reached the network:
|
|
192
|
+
# PrePoint creates the pending entry to mark the context as piped, and
|
|
193
|
+
# then the budget gate refuses the call (rbac, or an unresolvable
|
|
194
|
+
# endpoint, short-circuits just as early). Committing it would count a
|
|
195
|
+
# call that never happened and file a zero-amount record as `last`.
|
|
196
|
+
#
|
|
197
|
+
# A SUCCEEDED operation that made no attempt is the opposite case: it was
|
|
198
|
+
# served from the cache. That is a real call, and the fact that it cost
|
|
199
|
+
# nothing is the whole point of ordering cost inside the cache.
|
|
200
|
+
return if !done && 0 == entry["attempts"]
|
|
201
|
+
|
|
202
|
+
entity = ctx.op && ctx.op.entity ? ctx.op.entity : "_"
|
|
203
|
+
opname = ctx.op && ctx.op.name ? ctx.op.name : "_"
|
|
204
|
+
|
|
205
|
+
_commit(ctx, entry, entity, opname)
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
# Commit one operation's spend: totals, budget, per-op and per-actor
|
|
209
|
+
# attribution, and the record. Shared by _finish and the raw-request path in
|
|
210
|
+
# charge, which has no PreDone to reach.
|
|
211
|
+
def _commit(ctx, entry, entity, opname)
|
|
212
|
+
cost = @client.instance_variable_get(:@_cost)
|
|
213
|
+
return if cost.nil?
|
|
214
|
+
|
|
215
|
+
amount = entry["amount"]
|
|
216
|
+
reported = entry["reported"]
|
|
217
|
+
estimated = entry["estimated"]
|
|
218
|
+
source = entry["source"]
|
|
219
|
+
|
|
220
|
+
# A body figure prices the whole call, so it replaces the per-attempt
|
|
221
|
+
# estimate rather than adding to it, and being server-stated the whole
|
|
222
|
+
# amount counts as reported.
|
|
223
|
+
body = _body(ctx)
|
|
224
|
+
unless body.nil?
|
|
225
|
+
amount = body
|
|
226
|
+
reported = body
|
|
227
|
+
estimated = 0
|
|
228
|
+
source = "body"
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
_spend(cost, amount, reported, estimated)
|
|
232
|
+
|
|
233
|
+
actor = _actor(ctx)
|
|
234
|
+
|
|
235
|
+
cost["total"]["calls"] += 1
|
|
236
|
+
_bump(cost["ops"], "#{entity}.#{opname}", amount)
|
|
237
|
+
_bump(cost["actors"], actor, amount)
|
|
238
|
+
|
|
239
|
+
@seq += 1
|
|
240
|
+
record = {
|
|
241
|
+
"seq" => @seq,
|
|
242
|
+
"entity" => entity,
|
|
243
|
+
"op" => opname,
|
|
244
|
+
"actor" => actor,
|
|
245
|
+
"amount" => amount,
|
|
246
|
+
"currency" => cost["currency"],
|
|
247
|
+
"source" => source,
|
|
248
|
+
"attempts" => entry["attempts"],
|
|
249
|
+
}
|
|
250
|
+
cost["last"] = record
|
|
251
|
+
|
|
252
|
+
sink = @options["sink"]
|
|
253
|
+
if sink.is_a?(Proc)
|
|
254
|
+
begin
|
|
255
|
+
sink.call(record)
|
|
256
|
+
rescue StandardError
|
|
257
|
+
# A failing sink must never take down the call.
|
|
258
|
+
end
|
|
259
|
+
end
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
# Price one attempt: a reported header figure, else the rate table, else
|
|
263
|
+
# the flat unit.
|
|
264
|
+
def _price(ctx, res)
|
|
265
|
+
header = @options["header"]
|
|
266
|
+
if header.is_a?(String) && !header.empty?
|
|
267
|
+
val = _header(res, header)
|
|
268
|
+
return [val * _per_unit, "header"] unless val.nil?
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
rate = _rate(ctx)
|
|
272
|
+
return [rate, "table"] unless rate.nil?
|
|
273
|
+
|
|
274
|
+
unit = @options["unit"]
|
|
275
|
+
return [unit, "unit"] if unit.is_a?(Numeric) && unit != 0
|
|
276
|
+
|
|
277
|
+
[0, "none"]
|
|
278
|
+
end
|
|
279
|
+
|
|
280
|
+
# The rate table uses the same lookup grammar as rbac's rules:
|
|
281
|
+
# "<entity>.<op>", then "<op>", then "*".
|
|
282
|
+
def _rate(ctx)
|
|
283
|
+
rates = @options["rates"]
|
|
284
|
+
return nil unless rates.is_a?(Hash)
|
|
285
|
+
|
|
286
|
+
entity = if ctx.entity && ctx.entity.respond_to?(:name) && ctx.entity.name
|
|
287
|
+
ctx.entity.name
|
|
288
|
+
elsif ctx.op && ctx.op.entity
|
|
289
|
+
ctx.op.entity
|
|
290
|
+
else
|
|
291
|
+
""
|
|
292
|
+
end
|
|
293
|
+
opname = ctx.op && ctx.op.name ? ctx.op.name : ""
|
|
294
|
+
|
|
295
|
+
["#{entity}.#{opname}", opname, "*"].each do |key|
|
|
296
|
+
val = rates[key]
|
|
297
|
+
return val if val.is_a?(Numeric)
|
|
298
|
+
end
|
|
299
|
+
nil
|
|
300
|
+
end
|
|
301
|
+
|
|
302
|
+
# A usage figure from the parsed result body, priced by perUnit. Read
|
|
303
|
+
# here, not at the transport seam, because the body is one-shot.
|
|
304
|
+
def _body(ctx)
|
|
305
|
+
path = @options["path"]
|
|
306
|
+
return nil unless path.is_a?(String) && !path.empty?
|
|
307
|
+
return nil if ctx.result.nil? || ctx.result.body.nil?
|
|
308
|
+
|
|
309
|
+
val = VoxgigStruct.getpath(ctx.result.body, path)
|
|
310
|
+
num = _num(val)
|
|
311
|
+
return nil if num.nil?
|
|
312
|
+
num * _per_unit
|
|
313
|
+
end
|
|
314
|
+
|
|
315
|
+
def _spend(cost, amount, reported, estimated)
|
|
316
|
+
cost["total"]["amount"] += amount
|
|
317
|
+
cost["total"]["reported"] += reported
|
|
318
|
+
cost["total"]["estimated"] += estimated
|
|
319
|
+
|
|
320
|
+
limit = cost["budget"]["limit"]
|
|
321
|
+
cost["budget"]["spent"] = cost["total"]["amount"]
|
|
322
|
+
if limit > 0
|
|
323
|
+
cost["budget"]["remaining"] = [0, limit - cost["total"]["amount"]].max
|
|
324
|
+
cost["budget"]["exceeded"] = true if cost["total"]["amount"] >= limit
|
|
325
|
+
else
|
|
326
|
+
cost["budget"]["remaining"] = 0
|
|
327
|
+
end
|
|
328
|
+
end
|
|
329
|
+
|
|
330
|
+
def _bump(bucket, key, amount)
|
|
331
|
+
entry = bucket[key]
|
|
332
|
+
if entry.nil?
|
|
333
|
+
entry = { "calls" => 0, "amount" => 0 }
|
|
334
|
+
bucket[key] = entry
|
|
335
|
+
end
|
|
336
|
+
entry["calls"] += 1
|
|
337
|
+
entry["amount"] += amount
|
|
338
|
+
end
|
|
339
|
+
|
|
340
|
+
def _header(res, name)
|
|
341
|
+
return nil unless res.is_a?(Hash)
|
|
342
|
+
headers = res["headers"]
|
|
343
|
+
return nil unless headers.is_a?(Hash)
|
|
344
|
+
lower = name.downcase
|
|
345
|
+
headers.each do |key, val|
|
|
346
|
+
return _num(val) if key.to_s.downcase == lower
|
|
347
|
+
end
|
|
348
|
+
nil
|
|
349
|
+
end
|
|
350
|
+
|
|
351
|
+
def _num(val)
|
|
352
|
+
return val if val.is_a?(Numeric) && !val.is_a?(TrueClass) && !val.is_a?(FalseClass)
|
|
353
|
+
if val.is_a?(String)
|
|
354
|
+
begin
|
|
355
|
+
return Float(val.strip)
|
|
356
|
+
rescue ArgumentError, TypeError
|
|
357
|
+
return nil
|
|
358
|
+
end
|
|
359
|
+
end
|
|
360
|
+
nil
|
|
361
|
+
end
|
|
362
|
+
|
|
363
|
+
def _actor(ctx)
|
|
364
|
+
if ctx.ctrl && ctx.ctrl.respond_to?(:actor) && !ctx.ctrl.actor.nil?
|
|
365
|
+
return ctx.ctrl.actor
|
|
366
|
+
end
|
|
367
|
+
@options["actor"] || "anonymous"
|
|
368
|
+
end
|
|
369
|
+
|
|
370
|
+
def _per_unit
|
|
371
|
+
per = @options["perUnit"]
|
|
372
|
+
per.is_a?(Numeric) ? per : 0
|
|
373
|
+
end
|
|
374
|
+
|
|
375
|
+
def _limit
|
|
376
|
+
budget = @options["budget"]
|
|
377
|
+
budget.is_a?(Numeric) ? budget : 0
|
|
378
|
+
end
|
|
379
|
+
|
|
380
|
+
# Render a money amount without an exponent or trailing zeros.
|
|
381
|
+
def _numstr(n)
|
|
382
|
+
return n.to_s if n.is_a?(Integer)
|
|
383
|
+
s = format("%.10f", n.to_f).sub(/0+\z/, "").sub(/\.\z/, "")
|
|
384
|
+
s.empty? ? "0" : s
|
|
385
|
+
end
|
|
386
|
+
end
|
|
File without changes
|