@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.
Files changed (64) hide show
  1. package/bin/voxgig-sdkgen +1 -1
  2. package/dist/helpers/naming.js +5 -4
  3. package/dist/helpers/naming.js.map +1 -1
  4. package/dist/tsconfig.tsbuildinfo +1 -1
  5. package/package.json +1 -1
  6. package/project/.sdk/model/feature/cost.aon +43 -0
  7. package/project/.sdk/model/feature/feature-index.aon +1 -0
  8. package/project/.sdk/src/cmp/js/fragment/EntityCreateOp.fragment.js +2 -1
  9. package/project/.sdk/src/cmp/js/fragment/EntityListOp.fragment.js +2 -1
  10. package/project/.sdk/src/cmp/js/fragment/EntityLoadOp.fragment.js +2 -1
  11. package/project/.sdk/src/cmp/js/fragment/EntityRemoveOp.fragment.js +2 -1
  12. package/project/.sdk/src/cmp/js/fragment/EntityUpdateOp.fragment.js +2 -1
  13. package/project/.sdk/src/cmp/py/ReadmeExamplesTest_py.ts +15 -5
  14. package/project/.sdk/src/cmp/ts/fragment/EntityCreateOp.fragment.ts +2 -1
  15. package/project/.sdk/src/cmp/ts/fragment/EntityListOp.fragment.ts +2 -1
  16. package/project/.sdk/src/cmp/ts/fragment/EntityLoadOp.fragment.ts +2 -1
  17. package/project/.sdk/src/cmp/ts/fragment/EntityRemoveOp.fragment.ts +2 -1
  18. package/project/.sdk/src/cmp/ts/fragment/EntityUpdateOp.fragment.ts +2 -1
  19. package/project/.sdk/tm/c/core/sdk.h +1 -0
  20. package/project/.sdk/tm/c/feature/cost.c +549 -0
  21. package/project/.sdk/tm/c/src/feature/cost/.gitkeep +0 -0
  22. package/project/.sdk/tm/clojure/src/feature/cost/.gitkeep +0 -0
  23. package/project/.sdk/tm/cpp/feature/cost.hpp +419 -0
  24. package/project/.sdk/tm/cpp/src/feature/cost/.gitkeep +0 -0
  25. package/project/.sdk/tm/csharp/feature/CostFeature.cs +518 -0
  26. package/project/.sdk/tm/csharp/src/feature/cost/.gitkeep +0 -0
  27. package/project/.sdk/tm/dart/lib/feature/cost/CostFeature.dart +446 -0
  28. package/project/.sdk/tm/dart/src/feature/cost/.gitkeep +0 -0
  29. package/project/.sdk/tm/elixir/lib/projectname/feature/cost.ex +442 -0
  30. package/project/.sdk/tm/elixir/src/feature/cost/.gitkeep +0 -0
  31. package/project/.sdk/tm/go/feature/cost_feature.go +424 -0
  32. package/project/.sdk/tm/go/src/feature/cost/.gitkeep +0 -0
  33. package/project/.sdk/tm/go-cli/src/feature/cost/.gitkeep +0 -0
  34. package/project/.sdk/tm/go-mcp/src/feature/cost/.gitkeep +0 -0
  35. package/project/.sdk/tm/java/feature/CostFeature.java +431 -0
  36. package/project/.sdk/tm/java/src/feature/cost/.gitkeep +0 -0
  37. package/project/.sdk/tm/js/src/feature/cost/CostFeature.js +413 -0
  38. package/project/.sdk/tm/kotlin/feature/CostFeature.kt +430 -0
  39. package/project/.sdk/tm/kotlin/src/feature/cost/.gitkeep +0 -0
  40. package/project/.sdk/tm/lean/src/feature/cost/.gitkeep +0 -0
  41. package/project/.sdk/tm/lua/feature/cost_feature.lua +457 -0
  42. package/project/.sdk/tm/lua/src/feature/cost/.gitkeep +0 -0
  43. package/project/.sdk/tm/ocaml/src/feature/cost/.gitkeep +0 -0
  44. package/project/.sdk/tm/perl/feature/cost_feature.pm +446 -0
  45. package/project/.sdk/tm/perl/src/feature/cost/.gitkeep +0 -0
  46. package/project/.sdk/tm/php/feature/CostFeature.php +439 -0
  47. package/project/.sdk/tm/php/feature/TestFeature.php +31 -1
  48. package/project/.sdk/tm/php/src/feature/cost/.gitkeep +0 -0
  49. package/project/.sdk/tm/py/pkg/feature/cost_feature.py +381 -0
  50. package/project/.sdk/tm/py/src/feature/cost/.gitkeep +0 -0
  51. package/project/.sdk/tm/py-data/src/feature/cost/.gitkeep +0 -0
  52. package/project/.sdk/tm/rb/feature/cost_feature.rb +386 -0
  53. package/project/.sdk/tm/rb/src/feature/cost/.gitkeep +0 -0
  54. package/project/.sdk/tm/rust/feature/cost.rs +467 -0
  55. package/project/.sdk/tm/rust/src/feature/cost/.gitkeep +0 -0
  56. package/project/.sdk/tm/scala/feature/CostFeature.scala +388 -0
  57. package/project/.sdk/tm/scala/src/feature/cost/.gitkeep +0 -0
  58. package/project/.sdk/tm/swift/Sources/ProjectNameSDK/feature/CostFeature.swift +425 -0
  59. package/project/.sdk/tm/swift/src/feature/cost/.gitkeep +0 -0
  60. package/project/.sdk/tm/ts/src/feature/cost/CostFeature.ts +415 -0
  61. package/project/.sdk/tm/zig/feature/cost.zig +457 -0
  62. package/project/.sdk/tm/zig/src/feature/cost/.gitkeep +0 -0
  63. package/project/sdkgen-package.json +2 -1
  64. 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