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