@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,442 @@
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 OPERATION.
6
+ # So the transport wrap prices each attempt, and PreDone attributes the
7
+ # running total to `<entity>.<op>` and to the caller (`ctrl.actor`, the same
8
+ # 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 instead,
14
+ # from the already-parsed result. A body figure 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 is
18
+ # 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. Aggregates live on
23
+ # `client._cost`; the per-operation accumulator lives on the ctx, the same
24
+ # place metrics keeps its start marker.
25
+
26
+ defmodule ProjectName.Feature.Cost do
27
+ alias Voxgig.Struct, as: S
28
+ alias ProjectName.Helpers, as: H
29
+ alias ProjectName.{Feature, Context}
30
+
31
+ def new do
32
+ f = Feature.base("cost")
33
+ Feature.install(f, "init", fn ctx, opts -> init(f, ctx, opts) end)
34
+ Feature.install(f, "PrePoint", fn ctx -> pre_point(f, ctx) end)
35
+ Feature.install(f, "PreDone", fn ctx -> finish(f, ctx, true) end)
36
+ Feature.install(f, "PreUnexpected", fn ctx -> finish(f, ctx, false) end)
37
+ f
38
+ end
39
+
40
+ def init(f, ctx, options) do
41
+ active = Feature.init_common(f, ctx, options)
42
+
43
+ client = S.getprop(f, "client")
44
+ opts = Feature.opts(f)
45
+ lim = limit(opts)
46
+
47
+ Feature.track_node(
48
+ client,
49
+ "_cost",
50
+ S.jm([
51
+ "currency", H.or_(S.getprop(opts, "currency"), "USD"),
52
+ "total", S.jm(["calls", 0, "attempts", 0, "amount", 0, "reported", 0, "estimated", 0]),
53
+ "ops", S.jm([]),
54
+ "actors", S.jm([]),
55
+ "budget", S.jm(["limit", lim, "spent", 0, "remaining", lim, "exceeded", false]),
56
+ "last", nil
57
+ ])
58
+ )
59
+
60
+ S.setprop(f, "seq", 0)
61
+
62
+ if active do
63
+ utility = S.getprop(ctx, "utility")
64
+ inner = S.getprop(utility, "fetcher")
65
+ S.setprop(utility, "fetcher", fn fctx, url, fd -> charge(f, fctx, url, fd, inner) end)
66
+ end
67
+
68
+ nil
69
+ end
70
+
71
+ # Budget gate. Runs before endpoint resolution, so a refused call costs
72
+ # nothing at all.
73
+ defp pre_point(f, ctx) do
74
+ if not Feature.active?(f) do
75
+ nil
76
+ else
77
+ # Mark the context as running through the pipeline, so charge knows a
78
+ # PreDone is coming and does not commit the spend itself.
79
+ pending = pending_for(ctx)
80
+ S.setprop(pending, "piped", true)
81
+
82
+ opts = Feature.opts(f)
83
+ lim = limit(opts)
84
+ cost = S.getprop(S.getprop(f, "client"), "_cost")
85
+ total = S.getprop(cost, "total")
86
+
87
+ cond do
88
+ lim <= 0 ->
89
+ nil
90
+
91
+ S.getprop(total, "amount") < lim ->
92
+ nil
93
+
94
+ true ->
95
+ S.setprop(S.getprop(cost, "budget"), "exceeded", true)
96
+
97
+ if S.getprop(opts, "onBudget") != "deny" do
98
+ nil
99
+ else
100
+ currency = S.getprop(cost, "currency")
101
+
102
+ err =
103
+ Context.make_error(
104
+ ctx,
105
+ "cost_budget",
106
+ "Cost budget of " <>
107
+ to_string(lim) <>
108
+ " " <>
109
+ to_string(currency) <>
110
+ " is spent (" <>
111
+ to_string(S.getprop(total, "amount")) <>
112
+ " " <> to_string(currency) <> " used)"
113
+ )
114
+
115
+ # Short-circuit endpoint resolution; the pipeline surfaces this error.
116
+ S.setprop(S.getprop(ctx, "out"), "point", err)
117
+ err
118
+ end
119
+ end
120
+ end
121
+ end
122
+
123
+ # A failing transport still costs an attempt. Without this, a run of
124
+ # connection-level failures under `retry` (which retries on an error) would
125
+ # be charged nothing at all, and an onBudget = "deny" ceiling could never
126
+ # stop it.
127
+ defp charge(f, ctx, url, fetchdef, inner) do
128
+ {res, err, raised} =
129
+ try do
130
+ {r, e} = inner.(ctx, url, fetchdef)
131
+ {r, e, nil}
132
+ rescue
133
+ e -> {nil, nil, e}
134
+ end
135
+
136
+ opts = Feature.opts(f)
137
+ {amount, source} = price(f, ctx, if(raised != nil, do: nil, else: res))
138
+
139
+ cost = S.getprop(S.getprop(f, "client"), "_cost")
140
+ total = S.getprop(cost, "total")
141
+
142
+ pending = pending_for(ctx)
143
+
144
+ # Accumulated here, committed once at PreDone. Adding each attempt to the
145
+ # running total and then subtracting it again when a body figure
146
+ # supersedes it loses precision to catastrophic cancellation
147
+ # (5 + (0.01 - 5) is not 0.01 in binary floating point).
148
+ #
149
+ # Reported and estimated are kept apart per ATTEMPT, not per operation: a
150
+ # 503 priced from the rate table followed by a 200 carrying the cost
151
+ # header is part estimate, part reported, and collapsing both into the
152
+ # final attempt's category would corrupt the split.
153
+ S.setprop(pending, "attempts", S.getprop(pending, "attempts") + 1)
154
+ S.setprop(pending, "amount", S.getprop(pending, "amount") + amount)
155
+
156
+ bucket = if source == "header" or source == "body", do: "reported", else: "estimated"
157
+ S.setprop(pending, bucket, S.getprop(pending, bucket) + amount)
158
+ S.setprop(pending, "source", source)
159
+
160
+ S.setprop(total, "attempts", S.getprop(total, "attempts") + 1)
161
+
162
+ # direct() and graphql() reach the transport without dispatching any
163
+ # pipeline hooks - no PrePoint to gate on, and no PreDone to commit. Their
164
+ # spend is committed here instead, or it would never be counted and could
165
+ # run past an onBudget = "deny" ceiling indefinitely. `piped` is set by
166
+ # PrePoint, so its absence is the signal.
167
+ if S.getprop(pending, "piped") != true do
168
+ commit(f, ctx, pending, "_", "direct", opts)
169
+ S.delprop(ctx, "_cost_pending")
170
+ end
171
+
172
+ if raised != nil, do: raise(raised), else: {res, err}
173
+ end
174
+
175
+ defp pending_for(ctx) do
176
+ pending = S.getprop(ctx, "_cost_pending")
177
+
178
+ if pending == nil do
179
+ p =
180
+ S.jm([
181
+ "attempts", 0,
182
+ "amount", 0,
183
+ "reported", 0,
184
+ "estimated", 0,
185
+ "source", "none",
186
+ "piped", false
187
+ ])
188
+
189
+ S.setprop(ctx, "_cost_pending", p)
190
+ p
191
+ else
192
+ pending
193
+ end
194
+ end
195
+
196
+ # PreDone attributes the operation's spend; PreUnexpected commits a FAILED
197
+ # operation's, since PreDone never runs when the pipeline raises. Whichever
198
+ # fires first consumes the pending entry, so it commits exactly once.
199
+ defp finish(f, ctx, done) do
200
+ if not Feature.active?(f) do
201
+ nil
202
+ else
203
+ pending = S.getprop(ctx, "_cost_pending")
204
+
205
+ cond do
206
+ pending == nil ->
207
+ nil
208
+
209
+ # A FAILED operation that made no attempt never reached the network:
210
+ # PrePoint creates the pending entry to mark the context as piped, and
211
+ # then the budget gate refuses the call (rbac, or an unresolvable
212
+ # endpoint, short-circuits just as early). Committing it would count a
213
+ # call that never happened and file a zero-amount record as `last`.
214
+ #
215
+ # A SUCCEEDED operation that made no attempt is the opposite case: it
216
+ # was served from the cache. That is a real call, and the fact that it
217
+ # cost nothing is the whole point of ordering cost inside the cache.
218
+ not done and S.getprop(pending, "attempts") == 0 ->
219
+ S.delprop(ctx, "_cost_pending")
220
+ nil
221
+
222
+ true ->
223
+ S.delprop(ctx, "_cost_pending")
224
+ commit(f, ctx, pending, entity_of(ctx), opname_of(ctx), Feature.opts(f))
225
+ end
226
+ end
227
+ end
228
+
229
+ # Commit one operation's spend: totals, budget, per-op and per-actor
230
+ # attribution, and the record. Shared by finish and the raw-request path in
231
+ # charge, which has no PreDone to reach.
232
+ defp commit(f, ctx, pending, entity, opname, opts) do
233
+ cost = S.getprop(S.getprop(f, "client"), "_cost")
234
+ total = S.getprop(cost, "total")
235
+
236
+ # A body figure prices the whole call, so it replaces the per-attempt
237
+ # estimate rather than adding to it - and, being server-stated, the whole
238
+ # amount counts as reported.
239
+ body = body_amount(ctx, opts)
240
+
241
+ {amount, reported, estimated, source} =
242
+ if body == nil do
243
+ {S.getprop(pending, "amount"), S.getprop(pending, "reported"),
244
+ S.getprop(pending, "estimated"), S.getprop(pending, "source")}
245
+ else
246
+ {body, body, 0, "body"}
247
+ end
248
+
249
+ spend(cost, amount, reported, estimated)
250
+
251
+ ctrl = S.getprop(ctx, "ctrl")
252
+ actor = if ctrl != nil, do: S.getprop(ctrl, "actor"), else: nil
253
+ actor = if actor == nil, do: S.getprop(opts, "actor"), else: actor
254
+ actor = if actor == nil, do: "anonymous", else: actor
255
+
256
+ S.setprop(total, "calls", S.getprop(total, "calls") + 1)
257
+ bump(S.getprop(cost, "ops"), entity <> "." <> opname, amount)
258
+ bump(S.getprop(cost, "actors"), to_string(actor), amount)
259
+
260
+ seq = H.or_(S.getprop(f, "seq"), 0) + 1
261
+ S.setprop(f, "seq", seq)
262
+
263
+ record =
264
+ S.jm([
265
+ "seq", seq,
266
+ "entity", entity,
267
+ "op", opname,
268
+ "actor", actor,
269
+ "amount", amount,
270
+ "currency", S.getprop(cost, "currency"),
271
+ "source", source,
272
+ "attempts", S.getprop(pending, "attempts")
273
+ ])
274
+
275
+ S.setprop(cost, "last", record)
276
+
277
+ sink = S.getprop(opts, "sink")
278
+
279
+ if S.isfunc(sink) do
280
+ # A sink must never break the call it is reporting on.
281
+ try do
282
+ sink.(record)
283
+ rescue
284
+ _ -> nil
285
+ end
286
+ end
287
+
288
+ nil
289
+ end
290
+
291
+ # Price one attempt: a reported header figure, else the rate table, else the
292
+ # flat unit.
293
+ defp price(f, ctx, res) do
294
+ opts = Feature.opts(f)
295
+ header = S.getprop(opts, "header")
296
+
297
+ hv =
298
+ if is_binary(header) and header != "" do
299
+ header_num(res, header)
300
+ else
301
+ nil
302
+ end
303
+
304
+ cond do
305
+ hv != nil ->
306
+ {hv * per_unit(opts), "header"}
307
+
308
+ true ->
309
+ rate = rate_for(ctx, opts)
310
+ unit = S.getprop(opts, "unit")
311
+
312
+ cond do
313
+ rate != nil -> {rate, "table"}
314
+ is_number(unit) and unit != 0 -> {unit, "unit"}
315
+ true -> {0, "none"}
316
+ end
317
+ end
318
+ end
319
+
320
+ # The rate table uses the same lookup grammar as rbac's rules:
321
+ # `<entity>.<op>`, then `<op>`, then `*`.
322
+ defp rate_for(ctx, opts) do
323
+ rates = S.getprop(opts, "rates")
324
+ rates = if S.ismap(rates), do: rates, else: S.jm([])
325
+
326
+ entity = entity_of(ctx)
327
+ opname = opname_of(ctx)
328
+
329
+ Enum.find_value([entity <> "." <> opname, opname, "*"], fn key ->
330
+ v = S.getprop(rates, key)
331
+ if is_number(v) and not is_boolean(v), do: v
332
+ end)
333
+ end
334
+
335
+ # A usage figure from the parsed result body, priced by perUnit. Read here,
336
+ # not at the transport seam, because the body is consumed once.
337
+ defp body_amount(ctx, opts) do
338
+ path = S.getprop(opts, "path")
339
+ result = S.getprop(ctx, "result")
340
+ body = if result != nil, do: S.getprop(result, "body"), else: nil
341
+
342
+ if not is_binary(path) or path == "" or not S.ismap(body) do
343
+ nil
344
+ else
345
+ v = S.getpath(body, path)
346
+ if is_number(v) and not is_boolean(v), do: v * per_unit(opts), else: nil
347
+ end
348
+ end
349
+
350
+ defp spend(cost, amount, reported, estimated) do
351
+ total = S.getprop(cost, "total")
352
+ S.setprop(total, "amount", S.getprop(total, "amount") + amount)
353
+ S.setprop(total, "reported", S.getprop(total, "reported") + reported)
354
+ S.setprop(total, "estimated", S.getprop(total, "estimated") + estimated)
355
+
356
+ budget = S.getprop(cost, "budget")
357
+ lim = S.getprop(budget, "limit")
358
+ spent = S.getprop(total, "amount")
359
+ S.setprop(budget, "spent", spent)
360
+
361
+ if lim > 0 do
362
+ S.setprop(budget, "remaining", max(0, lim - spent))
363
+ if spent >= lim, do: S.setprop(budget, "exceeded", true)
364
+ else
365
+ S.setprop(budget, "remaining", 0)
366
+ end
367
+
368
+ nil
369
+ end
370
+
371
+ defp bump(bucket, key, amount) do
372
+ b = S.getprop(bucket, key)
373
+
374
+ b =
375
+ if b == nil do
376
+ nb = S.jm(["calls", 0, "amount", 0])
377
+ S.setprop(bucket, key, nb)
378
+ nb
379
+ else
380
+ b
381
+ end
382
+
383
+ S.setprop(b, "calls", S.getprop(b, "calls") + 1)
384
+ S.setprop(b, "amount", S.getprop(b, "amount") + amount)
385
+ nil
386
+ end
387
+
388
+ # HTTP header names are case-insensitive and a custom transport keeps
389
+ # conventional casing ("X-Request-Cost"), so header_get scans rather than
390
+ # indexes.
391
+ defp header_num(res, name) do
392
+ headers = if S.ismap(res), do: S.getprop(res, "headers"), else: nil
393
+
394
+ if not S.ismap(headers) do
395
+ nil
396
+ else
397
+ v = Feature.header_get(headers, name)
398
+
399
+ cond do
400
+ is_number(v) and not is_boolean(v) -> v
401
+ is_binary(v) -> parse_num(v)
402
+ true -> nil
403
+ end
404
+ end
405
+ end
406
+
407
+ defp parse_num(s) do
408
+ case Float.parse(String.trim(s)) do
409
+ {n, _} -> n
410
+ :error -> nil
411
+ end
412
+ end
413
+
414
+ defp entity_of(ctx) do
415
+ name = Context.entity_name(S.getprop(ctx, "entity"))
416
+ name = if name == "_", do: "", else: name
417
+
418
+ if name == "" do
419
+ op = S.getprop(ctx, "op")
420
+ oe = if op != nil, do: S.getprop(op, "entity"), else: nil
421
+ if is_binary(oe) and oe != "", do: oe, else: "_"
422
+ else
423
+ name
424
+ end
425
+ end
426
+
427
+ defp opname_of(ctx) do
428
+ op = S.getprop(ctx, "op")
429
+ nm = if op != nil, do: S.getprop(op, "name"), else: nil
430
+ if is_binary(nm) and nm != "", do: nm, else: "_"
431
+ end
432
+
433
+ defp per_unit(opts) do
434
+ p = S.getprop(opts, "perUnit")
435
+ if is_number(p) and not is_boolean(p), do: p, else: 0
436
+ end
437
+
438
+ defp limit(opts) do
439
+ b = S.getprop(opts, "budget")
440
+ if is_number(b) and not is_boolean(b), do: b, else: 0
441
+ end
442
+ end