@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,446 @@
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
+ use strict;
27
+ use warnings;
28
+
29
+ use File::Basename ();
30
+ use Cwd ();
31
+ use Scalar::Util ();
32
+
33
+ my $__dir;
34
+ BEGIN { $__dir = File::Basename::dirname(Cwd::abs_path(__FILE__)) }
35
+ require(Cwd::abs_path("$__dir/../lib/Voxgig/Struct.pm"));
36
+ require(Cwd::abs_path("$__dir/../core/helpers.pm"));
37
+ require(Cwd::abs_path("$__dir/base_feature.pm"));
38
+
39
+ package ProjectNameCostFeature;
40
+
41
+ our @ISA = ('ProjectNameBaseFeature');
42
+
43
+ sub new {
44
+ my ($class) = @_;
45
+ my $self = ProjectNameBaseFeature::new($class);
46
+ $self->{version} = '0.0.1';
47
+ $self->{name} = 'cost';
48
+ # Inactive until init (feature_init only fires init when active).
49
+ $self->{active} = 0;
50
+ $self->{client} = undef;
51
+ $self->{options} = {};
52
+ # Keyed by ctx refaddr, same as metrics' start markers.
53
+ $self->{pending} = {};
54
+ $self->{seq} = 0;
55
+ return $self;
56
+ }
57
+
58
+ sub init {
59
+ my ($self, $ctx, $options) = @_;
60
+ $self->{client} = $ctx->{client};
61
+ $self->{options} = Voxgig::Struct::ismap($options) ? $options : {};
62
+ $self->{active} = ProjectNameHelpers::is_true($self->{options}{active});
63
+ $self->{pending} = {};
64
+ $self->{seq} = 0;
65
+
66
+ my $limit = $self->_limit;
67
+
68
+ if (!$self->{client}{_cost}) {
69
+ $self->{client}{_cost} = {
70
+ 'currency' => defined $self->{options}{currency} ? $self->{options}{currency} : 'USD',
71
+ 'total' => {
72
+ 'calls' => 0, 'attempts' => 0,
73
+ 'amount' => 0, 'reported' => 0, 'estimated' => 0,
74
+ },
75
+ 'ops' => {},
76
+ 'actors' => {},
77
+ 'budget' => {
78
+ 'limit' => $limit, 'spent' => 0,
79
+ 'remaining' => $limit, 'exceeded' => 0,
80
+ },
81
+ 'last' => undef,
82
+ };
83
+ }
84
+
85
+ return unless $self->{active};
86
+
87
+ my $feature = $self;
88
+ my $utility = $ctx->{utility};
89
+ my $inner = $utility->{fetcher};
90
+
91
+ $utility->{fetcher} = sub {
92
+ my ($fctx, $fullurl, $fetchdef) = @_;
93
+ return $feature->charge($fctx, $fullurl, $fetchdef, $inner);
94
+ };
95
+ return;
96
+ }
97
+
98
+ # The budget gate. Runs before endpoint resolution, so a refused call costs
99
+ # nothing at all.
100
+ sub PrePoint {
101
+ my ($self, $ctx) = @_;
102
+ return unless $self->{active};
103
+
104
+ # Mark the context as running through the pipeline, so charge knows a
105
+ # PreDone is coming and does not commit the spend itself.
106
+ my $addr = Scalar::Util::refaddr($ctx);
107
+ my $entry = $self->{pending}{$addr};
108
+ if (!$entry) {
109
+ $entry = $self->_new_pending;
110
+ $self->{pending}{$addr} = $entry;
111
+ }
112
+ $entry->{piped} = 1;
113
+
114
+ my $limit = $self->_limit;
115
+ return if $limit <= 0;
116
+
117
+ my $cost = $self->{client}{_cost};
118
+ return unless $cost;
119
+ return if $cost->{total}{amount} < $limit;
120
+
121
+ $cost->{budget}{exceeded} = 1;
122
+
123
+ my $on = defined $self->{options}{onBudget} ? $self->{options}{onBudget} : 'warn';
124
+ return unless 'deny' eq $on;
125
+
126
+ my $err = $ctx->make_error('cost_budget',
127
+ 'Cost budget of ' . $self->_numstr($limit) . ' ' . $cost->{currency}
128
+ . ' is spent (' . $self->_numstr($cost->{total}{amount}) . ' '
129
+ . $cost->{currency} . ' used)');
130
+
131
+ # Short-circuit endpoint resolution; the pipeline surfaces this error.
132
+ $ctx->{out}{point} = $err;
133
+ return;
134
+ }
135
+
136
+ sub charge {
137
+ my ($self, $ctx, $url, $fetchdef, $inner) = @_;
138
+
139
+ # A dying transport still costs an attempt. Without this, a run of
140
+ # connection-level failures under "retry" (which traps and tries again)
141
+ # would be charged nothing at all, and an onBudget "deny" ceiling could
142
+ # never stop it.
143
+ my ($res, $err);
144
+ my $threw;
145
+ {
146
+ local $@;
147
+ my $ok = eval {
148
+ ($res, $err) = $inner->($ctx, $url, $fetchdef);
149
+ 1;
150
+ };
151
+ if (!$ok) {
152
+ $threw = $@ || "cost: transport died";
153
+ $res = undef;
154
+ $err = $threw;
155
+ }
156
+ }
157
+
158
+ my ($amount, $source) = $self->_price($ctx, $res);
159
+
160
+ my $addr = Scalar::Util::refaddr($ctx);
161
+ my $entry = $self->{pending}{$addr};
162
+ if (!$entry) {
163
+ $entry = $self->_new_pending;
164
+ $self->{pending}{$addr} = $entry;
165
+ }
166
+
167
+ $entry->{attempts} += 1;
168
+
169
+ # Accumulated here, committed once at PreDone. Adding each attempt to the
170
+ # running total and then subtracting it again when a body figure
171
+ # supersedes it loses precision to catastrophic cancellation.
172
+ #
173
+ # Reported and estimated are kept apart per ATTEMPT: a 503 priced from the
174
+ # rate table followed by a 200 carrying the cost header is part estimate,
175
+ # part reported, and collapsing both into the final attempt's category
176
+ # would corrupt the split.
177
+ $entry->{amount} += $amount;
178
+ my $bucket = ('header' eq $source || 'body' eq $source) ? 'reported' : 'estimated';
179
+ $entry->{$bucket} += $amount;
180
+ $entry->{source} = $source;
181
+
182
+ my $cost = $self->{client}{_cost};
183
+ $cost->{total}{attempts} += 1 if $cost;
184
+
185
+ # direct() and graphql() reach the transport without dispatching any
186
+ # pipeline hooks, so there is no PrePoint to gate on and no PreDone to
187
+ # commit. Their spend is committed here, or it would never be counted.
188
+ # "piped" is set by PrePoint, so its absence is the signal.
189
+ if (!$entry->{piped}) {
190
+ $self->_commit($ctx, $entry, '_', 'direct');
191
+ delete $self->{pending}{$addr};
192
+ }
193
+
194
+ die $threw if defined $threw;
195
+
196
+ return ($res, $err);
197
+ }
198
+
199
+ sub _new_pending {
200
+ my ($self) = @_;
201
+ return {
202
+ 'attempts' => 0, 'amount' => 0,
203
+ 'reported' => 0, 'estimated' => 0,
204
+ 'source' => 'none', 'piped' => 0,
205
+ };
206
+ }
207
+
208
+ # Attribute the operation's spend once the call is finished.
209
+ sub PreDone {
210
+ my ($self, $ctx) = @_;
211
+ $self->_finish($ctx, 1);
212
+ return;
213
+ }
214
+
215
+ # A failed operation still spent the money. When the pipeline dies, PreDone
216
+ # never runs, so without this the attempts are counted and the spend is not,
217
+ # and a budget could never see the cost of a failed call. Whichever hook fires
218
+ # first consumes the pending entry, so it commits exactly once.
219
+ sub PreUnexpected {
220
+ my ($self, $ctx) = @_;
221
+ $self->_finish($ctx, 0);
222
+ return;
223
+ }
224
+
225
+ sub _finish {
226
+ my ($self, $ctx, $done) = @_;
227
+ return unless $self->{active};
228
+ my $addr = Scalar::Util::refaddr($ctx);
229
+ return unless exists $self->{pending}{$addr};
230
+ my $entry = delete $self->{pending}{$addr};
231
+
232
+ # A FAILED operation that made no attempt never reached the network:
233
+ # PrePoint creates the pending entry to mark the context as piped, and
234
+ # then the budget gate refuses the call (rbac, or an unresolvable
235
+ # endpoint, short-circuits just as early). Committing it would count a
236
+ # call that never happened and file a zero-amount record as `last`.
237
+ #
238
+ # A SUCCEEDED operation that made no attempt is the opposite case: it was
239
+ # served from the cache. That is a real call, and the fact that it cost
240
+ # nothing is the whole point of ordering cost inside the cache.
241
+ return if !$done && 0 == $entry->{attempts};
242
+
243
+ my $entity = ($ctx->{op} && defined $ctx->{op}{entity} && '' ne $ctx->{op}{entity})
244
+ ? $ctx->{op}{entity} : '_';
245
+ my $opname = ($ctx->{op} && defined $ctx->{op}{name} && '' ne $ctx->{op}{name})
246
+ ? $ctx->{op}{name} : '_';
247
+
248
+ $self->_commit($ctx, $entry, $entity, $opname);
249
+ return;
250
+ }
251
+
252
+ # Commit one operation's spend: totals, budget, per-op and per-actor
253
+ # attribution, and the record. Shared by _finish and the raw-request path in
254
+ # charge, which has no PreDone to reach.
255
+ sub _commit {
256
+ my ($self, $ctx, $entry, $entity, $opname) = @_;
257
+
258
+ my $cost = $self->{client}{_cost};
259
+ return unless $cost;
260
+
261
+ my $amount = $entry->{amount};
262
+ my $reported = $entry->{reported};
263
+ my $estimated = $entry->{estimated};
264
+ my $source = $entry->{source};
265
+
266
+ # A body figure prices the whole call, so it replaces the per-attempt
267
+ # estimate rather than adding to it, and being server-stated the whole
268
+ # amount counts as reported.
269
+ my $body = $self->_body($ctx);
270
+ if (defined $body) {
271
+ $amount = $body;
272
+ $reported = $body;
273
+ $estimated = 0;
274
+ $source = 'body';
275
+ }
276
+
277
+ $self->_spend($cost, $amount, $reported, $estimated);
278
+
279
+ my $actor = $self->_actor($ctx);
280
+
281
+ $cost->{total}{calls} += 1;
282
+ $self->_bump($cost->{ops}, "$entity.$opname", $amount);
283
+ $self->_bump($cost->{actors}, $actor, $amount);
284
+
285
+ $self->{seq} += 1;
286
+ my $record = {
287
+ 'seq' => $self->{seq},
288
+ 'entity' => $entity,
289
+ 'op' => $opname,
290
+ 'actor' => $actor,
291
+ 'amount' => $amount,
292
+ 'currency' => $cost->{currency},
293
+ 'source' => $source,
294
+ 'attempts' => $entry->{attempts},
295
+ };
296
+ $cost->{last} = $record;
297
+
298
+ my $sink = $self->{options}{sink};
299
+ if (ref $sink eq 'CODE') {
300
+ # A failing sink must never take down the call.
301
+ eval { $sink->($record) };
302
+ }
303
+ return;
304
+ }
305
+
306
+ # Price one attempt: a reported header figure, else the rate table, else the
307
+ # flat unit.
308
+ sub _price {
309
+ my ($self, $ctx, $res) = @_;
310
+
311
+ my $header = $self->{options}{header};
312
+ if (defined $header && '' ne $header) {
313
+ my $val = $self->_header($res, $header);
314
+ return ($val * $self->_per_unit, 'header') if defined $val;
315
+ }
316
+
317
+ my $rate = $self->_rate($ctx);
318
+ return ($rate, 'table') if defined $rate;
319
+
320
+ my $unit = $self->{options}{unit};
321
+ return ($unit, 'unit')
322
+ if defined $unit && !ref($unit) && Scalar::Util::looks_like_number($unit) && 0 != $unit;
323
+
324
+ return (0, 'none');
325
+ }
326
+
327
+ # The rate table uses the same lookup grammar as rbac's rules:
328
+ # "<entity>.<op>", then "<op>", then "*".
329
+ sub _rate {
330
+ my ($self, $ctx) = @_;
331
+ my $rates = $self->{options}{rates};
332
+ return undef unless Voxgig::Struct::ismap($rates);
333
+
334
+ my $entity = '';
335
+ if ($ctx->{entity} && defined $ctx->{entity}{name}) {
336
+ $entity = $ctx->{entity}{name};
337
+ }
338
+ elsif ($ctx->{op} && defined $ctx->{op}{entity}) {
339
+ $entity = $ctx->{op}{entity};
340
+ }
341
+ my $opname = ($ctx->{op} && defined $ctx->{op}{name}) ? $ctx->{op}{name} : '';
342
+
343
+ for my $key ("$entity.$opname", $opname, '*') {
344
+ my $val = $rates->{$key};
345
+ return $val
346
+ if defined $val && !ref($val) && Scalar::Util::looks_like_number($val);
347
+ }
348
+ return undef;
349
+ }
350
+
351
+ # A usage figure from the parsed result body, priced by perUnit. Read here,
352
+ # not at the transport seam, because the body is one-shot.
353
+ sub _body {
354
+ my ($self, $ctx) = @_;
355
+ my $path = $self->{options}{path};
356
+ return undef unless defined $path && '' ne $path;
357
+ return undef unless $ctx->{result} && defined $ctx->{result}{body};
358
+
359
+ my $val = Voxgig::Struct::getpath($ctx->{result}{body}, $path);
360
+ my $num = $self->_num($val);
361
+ return undef unless defined $num;
362
+ return $num * $self->_per_unit;
363
+ }
364
+
365
+ sub _spend {
366
+ my ($self, $cost, $amount, $reported, $estimated) = @_;
367
+ $cost->{total}{amount} += $amount;
368
+ $cost->{total}{reported} += $reported;
369
+ $cost->{total}{estimated} += $estimated;
370
+
371
+ my $limit = $cost->{budget}{limit};
372
+ $cost->{budget}{spent} = $cost->{total}{amount};
373
+ if ($limit > 0) {
374
+ my $rem = $limit - $cost->{total}{amount};
375
+ $cost->{budget}{remaining} = $rem > 0 ? $rem : 0;
376
+ $cost->{budget}{exceeded} = 1 if $cost->{total}{amount} >= $limit;
377
+ }
378
+ else {
379
+ $cost->{budget}{remaining} = 0;
380
+ }
381
+ return;
382
+ }
383
+
384
+ sub _bump {
385
+ my ($self, $bucket, $key, $amount) = @_;
386
+ my $entry = $bucket->{$key};
387
+ if (!$entry) {
388
+ $entry = { 'calls' => 0, 'amount' => 0 };
389
+ $bucket->{$key} = $entry;
390
+ }
391
+ $entry->{calls} += 1;
392
+ $entry->{amount} += $amount;
393
+ return;
394
+ }
395
+
396
+ sub _header {
397
+ my ($self, $res, $name) = @_;
398
+ return undef unless Voxgig::Struct::ismap($res);
399
+ my $headers = $res->{headers};
400
+ return undef unless Voxgig::Struct::ismap($headers);
401
+ my $lower = lc($name);
402
+ for my $key (keys %$headers) {
403
+ return $self->_num($headers->{$key}) if lc("$key") eq $lower;
404
+ }
405
+ return undef;
406
+ }
407
+
408
+ sub _num {
409
+ my ($self, $val) = @_;
410
+ return undef unless defined $val;
411
+ return undef if ref $val;
412
+ return 0 + $val if Scalar::Util::looks_like_number($val);
413
+ return undef;
414
+ }
415
+
416
+ sub _actor {
417
+ my ($self, $ctx) = @_;
418
+ if ($ctx->{ctrl} && defined $ctx->{ctrl}{actor}) {
419
+ return $ctx->{ctrl}{actor};
420
+ }
421
+ return defined $self->{options}{actor} ? $self->{options}{actor} : 'anonymous';
422
+ }
423
+
424
+ sub _per_unit {
425
+ my ($self) = @_;
426
+ my $per = $self->{options}{perUnit};
427
+ return (defined $per && !ref($per) && Scalar::Util::looks_like_number($per)) ? $per : 0;
428
+ }
429
+
430
+ sub _limit {
431
+ my ($self) = @_;
432
+ my $budget = $self->{options}{budget};
433
+ return (defined $budget && !ref($budget) && Scalar::Util::looks_like_number($budget))
434
+ ? $budget : 0;
435
+ }
436
+
437
+ # Render a money amount without an exponent or trailing zeros.
438
+ sub _numstr {
439
+ my ($self, $n) = @_;
440
+ my $s = sprintf('%.10f', $n);
441
+ $s =~ s/0+$//;
442
+ $s =~ s/\.$//;
443
+ return '' eq $s ? '0' : $s;
444
+ }
445
+
446
+ 1;
File without changes