stellarium-ts 0.15.0 → 0.17.0

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 (39) hide show
  1. package/dist/client/operatorAuth.d.ts +19 -0
  2. package/dist/client/operatorAuth.d.ts.map +1 -0
  3. package/dist/client/operatorAuth.js +21 -0
  4. package/dist/client/operatorClient.d.ts +75 -16
  5. package/dist/client/operatorClient.d.ts.map +1 -1
  6. package/dist/client/operatorClient.js +82 -23
  7. package/dist/db/db.d.ts.map +1 -1
  8. package/dist/db/db.js +11 -3
  9. package/dist/index.d.ts +5 -2
  10. package/dist/index.d.ts.map +1 -1
  11. package/dist/index.js +4 -1
  12. package/dist/resource/inference.d.ts +38 -0
  13. package/dist/resource/inference.d.ts.map +1 -0
  14. package/dist/resource/inference.js +74 -0
  15. package/dist/resource/invoker.d.ts +65 -8
  16. package/dist/resource/invoker.d.ts.map +1 -1
  17. package/dist/resource/invoker.js +120 -21
  18. package/dist/resource/repository.d.ts +29 -5
  19. package/dist/resource/repository.d.ts.map +1 -1
  20. package/dist/resource/repository.js +38 -7
  21. package/dist/routes.d.ts.map +1 -1
  22. package/dist/routes.js +206 -52
  23. package/dist/server.d.ts +13 -3
  24. package/dist/server.d.ts.map +1 -1
  25. package/dist/server.js +26 -4
  26. package/dist/service.d.ts +8 -1
  27. package/dist/service.d.ts.map +1 -1
  28. package/dist/service.js +3 -3
  29. package/dist/tests/client/operatorClient.test.js +94 -36
  30. package/dist/tests/db/db.test.js +18 -1
  31. package/dist/tests/helpers.d.ts +11 -1
  32. package/dist/tests/helpers.d.ts.map +1 -1
  33. package/dist/tests/helpers.js +38 -3
  34. package/dist/tests/integration/network.test.js +78 -7
  35. package/dist/tests/resource/invoker.test.js +95 -4
  36. package/dist/tests/resource/repository.test.js +20 -6
  37. package/dist/tests/routes.test.js +194 -23
  38. package/dist/tests/server.test.js +35 -8
  39. package/package.json +1 -1
@@ -3,7 +3,7 @@ import { signData } from "../../crypto/utils.js";
3
3
  import { openDatabase } from "../../db/db.js";
4
4
  import { PeerRepository } from "../../peer/repository.js";
5
5
  import { resourceAnnouncementPayload, ResourceRepository } from "../../resource/repository.js";
6
- import { generateBase64KeyPair, RECIPIENT, weatherResource } from "../helpers.js";
6
+ import { generateBase64KeyPair, ORGANIZATION, weatherResource } from "../helpers.js";
7
7
  const SELF = "http://self.example";
8
8
  const PROVIDER = "http://provider.example";
9
9
  function resource(name, functions, baseUrl = PROVIDER) {
@@ -51,6 +51,20 @@ describe("ResourceRepository", () => {
51
51
  ]);
52
52
  expect((await repository.getResourceFunction("com.provider", "Forecast", "tomorrow"))?.description).toBe("Tomorrow's forecast");
53
53
  });
54
+ test("keeps inference functions' kind and per-token pricing", async () => {
55
+ const pricing = { inputCostMicroUsdPerMillionTokens: 3_000_000, outputCostMicroUsdPerMillionTokens: 0, recipientOrganizationId: ORGANIZATION };
56
+ await repository.learn(resource("Model", [
57
+ { name: "complete", kind: "inference", parameters: [{ name: "maxTokens", type: "int32" }], paymentManifest: pricing },
58
+ { name: "count", paymentManifest: { costMicroUsd: 5, recipientOrganizationId: ORGANIZATION } },
59
+ ]));
60
+ const complete = await repository.getResourceFunction("com.provider", "Model", "complete");
61
+ expect(complete).toMatchObject({ kind: "inference", paymentManifest: pricing });
62
+ const count = await repository.getResourceFunction("com.provider", "Model", "count");
63
+ expect(count?.kind).toBeUndefined();
64
+ expect(count?.paymentManifest).toEqual({ id: expect.any(Number), costMicroUsd: 5, recipientOrganizationId: ORGANIZATION });
65
+ const functions = (await repository.getResource("com.provider.Model"))?.functions;
66
+ expect(functions.find(fn => fn.name === "complete")).toMatchObject({ kind: "inference", paymentManifest: pricing });
67
+ });
54
68
  test("is idempotent, so startup can run on every boot", async () => {
55
69
  const learned = resource("Forecast", [{ name: "tomorrow" }]);
56
70
  await repository.learn(learned);
@@ -88,12 +102,12 @@ describe("ResourceRepository", () => {
88
102
  test("returns each function's own payment manifest", async () => {
89
103
  await repository.learn(resource("Forecast", [
90
104
  { name: "free" },
91
- { name: "paid", paymentManifest: { costMicroUsdc: 250, recipientAddress: RECIPIENT } },
105
+ { name: "paid", paymentManifest: { costMicroUsd: 250, recipientOrganizationId: ORGANIZATION } },
92
106
  ]));
93
107
  const found = await repository.getResource("com.provider.Forecast");
94
108
  expect(found.metadata).toEqual({ baseUrl: PROVIDER });
95
109
  expect(found.functions.find(f => f.name === "free")?.paymentManifest).toBeUndefined();
96
- expect(found.functions.find(f => f.name === "paid")?.paymentManifest).toMatchObject({ costMicroUsdc: 250, recipientAddress: RECIPIENT });
110
+ expect(found.functions.find(f => f.name === "paid")?.paymentManifest).toMatchObject({ costMicroUsd: 250, recipientOrganizationId: ORGANIZATION });
97
111
  });
98
112
  test("prefers the internal resource", async () => {
99
113
  const found = await repository.getResource("com.example.Weather");
@@ -119,13 +133,13 @@ describe("ResourceRepository", () => {
119
133
  test("adds new functions, parameters and payment manifests to a known resource", async () => {
120
134
  await save([resource("Forecast", [{ name: "today" }])]);
121
135
  const result = await save([resource("Forecast", [
122
- { name: "today", parameters: [{ name: "city", type: "string" }], paymentManifest: { costMicroUsdc: 100, recipientAddress: RECIPIENT } },
136
+ { name: "today", parameters: [{ name: "city", type: "string" }], paymentManifest: { costMicroUsd: 100, recipientOrganizationId: ORGANIZATION } },
123
137
  { name: "tomorrow" },
124
138
  ])]);
125
139
  expect(result).toEqual({ ok: true, rejected: [] });
126
140
  const today = await repository.getResourceFunction("com.provider", "Forecast", "today");
127
141
  expect(today?.parameters).toEqual([{ name: "city", type: "string" }]);
128
- expect(today?.paymentManifest).toMatchObject({ costMicroUsdc: 100 });
142
+ expect(today?.paymentManifest).toMatchObject({ costMicroUsd: 100 });
129
143
  expect(await repository.getResourceFunction("com.provider", "Forecast", "tomorrow")).toBeDefined();
130
144
  });
131
145
  test("drops descriptions that aren't strings and websites that aren't http(s) URLs", async () => {
@@ -142,7 +156,7 @@ describe("ResourceRepository", () => {
142
156
  expect([radar.website, radar.description]).toEqual(["https://radar.example", "Rain radar"]);
143
157
  });
144
158
  test("removes functions and payment manifests the peer no longer advertises", async () => {
145
- await save([resource("Forecast", [{ name: "today", paymentManifest: { costMicroUsdc: 100, recipientAddress: RECIPIENT } }, { name: "old" }])]);
159
+ await save([resource("Forecast", [{ name: "today", paymentManifest: { costMicroUsd: 100, recipientOrganizationId: ORGANIZATION } }, { name: "old" }])]);
146
160
  await save([resource("Forecast", [{ name: "today" }])]);
147
161
  expect((await repository.getResourceFunction("com.provider", "Forecast", "today"))?.paymentManifest).toBeUndefined();
148
162
  expect(await repository.getResourceFunction("com.provider", "Forecast", "old")).toBeUndefined();
@@ -6,11 +6,11 @@ import { grantRenewalPayload, grantRequestPayload } from "../grant/grant.js";
6
6
  import { GrantRepository } from "../grant/repository.js";
7
7
  import { GrantService } from "../grant/service.js";
8
8
  import { peerAnnouncementPayload } from "../peer/repository.js";
9
- import { CombinationResourceInvoker, paymentVoucherPayload } from "../resource/invoker.js";
9
+ import { CombinationResourceInvoker, inferenceVoucherPayload, paymentVoucherPayload } from "../resource/invoker.js";
10
10
  import { resourceAnnouncementPayload } from "../resource/repository.js";
11
11
  import routes from "../routes.js";
12
12
  import { createService } from "../service.js";
13
- import { COST, generateBase64KeyPair, RECIPIENT, temperatureSource, testConfig, weatherResource } from "./helpers.js";
13
+ import { COST, generateBase64KeyPair, INFERENCE_RATES, modelResource, ORGANIZATION, temperatureSource, testConfig, weatherResource } from "./helpers.js";
14
14
  const parameters = [{ name: "city", value: "Oslo" }];
15
15
  const inMinutes = (minutes) => new Date(Date.now() + minutes * 60_000).toISOString();
16
16
  describe("routes", () => {
@@ -22,12 +22,16 @@ describe("routes", () => {
22
22
  let config;
23
23
  let service;
24
24
  let fastify;
25
- let settlePayment;
25
+ let authorize;
26
+ let capture;
27
+ let release;
26
28
  beforeEach(async () => {
27
29
  db = await openDatabase(":memory:");
28
30
  config = testConfig();
29
31
  service = createService(db, [weather], config);
30
- settlePayment = vi.spyOn(service.operatorClient, "settlePayment").mockResolvedValue(undefined);
32
+ authorize = vi.spyOn(service.operatorClient, "authorize").mockResolvedValue({ ok: true });
33
+ capture = vi.spyOn(service.operatorClient, "capture").mockResolvedValue(undefined);
34
+ release = vi.spyOn(service.operatorClient, "release").mockResolvedValue(undefined);
31
35
  fastify = Fastify();
32
36
  await fastify.register(routes, { prefix: "/v1", service });
33
37
  await fastify.ready();
@@ -41,7 +45,7 @@ describe("routes", () => {
41
45
  const flushSettlement = () => new Promise((resolve) => setTimeout(resolve, 10));
42
46
  describe("POST /v1/run/resource", () => {
43
47
  function voucherFor(functionName = "temperature", overrides = {}) {
44
- const voucher = callerInvoker.createPaymentVoucher(RECIPIENT, COST, weather.path, weather.name, functionName, parameters);
48
+ const voucher = callerInvoker.createPaymentVoucher(ORGANIZATION, COST, weather.path, weather.name, functionName, parameters);
45
49
  if (Object.keys(overrides).length === 0) {
46
50
  return voucher;
47
51
  }
@@ -56,13 +60,30 @@ describe("routes", () => {
56
60
  payload: { resourcePath: weather.path, resourceName: weather.name, functionName: "temperature", parameters, ...body },
57
61
  });
58
62
  }
59
- test("runs a paid function with a valid voucher and settles it", async () => {
63
+ test("authorizes a paid function's voucher, runs it, then captures it", async () => {
60
64
  const paymentVoucher = voucherFor();
61
65
  const response = await runResource({ paymentVoucher });
62
66
  expect(response.statusCode).toBe(200);
63
67
  expect(response.json()).toEqual({ value: 12, error: null });
68
+ expect(authorize).toHaveBeenCalledExactlyOnceWith(paymentVoucher);
64
69
  await flushSettlement();
65
- expect(settlePayment).toHaveBeenCalledExactlyOnceWith({ voucher: paymentVoucher });
70
+ expect(capture).toHaveBeenCalledExactlyOnceWith({ voucher: paymentVoucher });
71
+ expect(release).not.toHaveBeenCalled();
72
+ });
73
+ test.each([
74
+ [402, "Insufficient balance"],
75
+ [403, "Payer organization is frozen"],
76
+ [503, "Payment operator is unavailable"],
77
+ ])("answers the operator's %i refusal without running the function", async (status, error) => {
78
+ authorize.mockResolvedValueOnce({ ok: false, status, error });
79
+ const exec = vi.spyOn(weather.functions[0], "exec");
80
+ const response = await runResource({ paymentVoucher: voucherFor() });
81
+ expect(response.statusCode).toBe(status);
82
+ expect(response.json()).toEqual({ error });
83
+ expect(exec).not.toHaveBeenCalled();
84
+ await flushSettlement();
85
+ expect(capture).not.toHaveBeenCalled();
86
+ expect(release).not.toHaveBeenCalled();
66
87
  });
67
88
  test("refuses a voucher from a blocked caller, even for an unrestricted function", async () => {
68
89
  await service.grantService.block(caller.publicKey);
@@ -70,7 +91,7 @@ describe("routes", () => {
70
91
  expect(response.statusCode).toBe(403);
71
92
  expect(response.json()).toEqual({ error: "Caller is blocked" });
72
93
  await flushSettlement();
73
- expect(settlePayment).not.toHaveBeenCalled();
94
+ expect(capture).not.toHaveBeenCalled();
74
95
  });
75
96
  test("accepts a voucher that pays more than the cost", async () => {
76
97
  const response = await runResource({ paymentVoucher: voucherFor("temperature", { amount: COST + 1 }) });
@@ -80,11 +101,11 @@ describe("routes", () => {
80
101
  const response = await runResource({});
81
102
  expect(response.statusCode).toBe(402);
82
103
  await flushSettlement();
83
- expect(settlePayment).not.toHaveBeenCalled();
104
+ expect(capture).not.toHaveBeenCalled();
84
105
  });
85
106
  test.each([
86
107
  ["pays less than the cost", { amount: COST - 1 }],
87
- ["pays a different recipient", { recipientAddress: "11111111111111111111111111111111" }],
108
+ ["pays a different recipient", { recipientOrganizationId: "org_other" }],
88
109
  ["has expired", { expiresAt: new Date(Date.now() - 1000).toISOString() }],
89
110
  ["has an unparseable expiry", { expiresAt: "not-a-date" }],
90
111
  ])("rejects a voucher that %s", async (_, overrides) => {
@@ -92,14 +113,14 @@ describe("routes", () => {
92
113
  expect(response.statusCode).toBe(400);
93
114
  expect(response.json()).toEqual({ error: "Invalid payment voucher" });
94
115
  await flushSettlement();
95
- expect(settlePayment).not.toHaveBeenCalled();
116
+ expect(capture).not.toHaveBeenCalled();
96
117
  });
97
118
  test("rejects a voucher whose parameters were tampered with", async () => {
98
119
  const response = await runResource({ paymentVoucher: voucherFor(), parameters: [{ name: "city", value: "Bergen" }] });
99
120
  expect(response.statusCode).toBe(400);
100
121
  });
101
122
  test("rejects a voucher issued for a different function", async () => {
102
- const paymentVoucher = callerInvoker.createPaymentVoucher(RECIPIENT, COST, weather.path, weather.name, "humidity", parameters);
123
+ const paymentVoucher = callerInvoker.createPaymentVoucher(ORGANIZATION, COST, weather.path, weather.name, "humidity", parameters);
103
124
  const response = await runResource({ paymentVoucher });
104
125
  expect(response.statusCode).toBe(400);
105
126
  });
@@ -122,21 +143,23 @@ describe("routes", () => {
122
143
  expect(response.statusCode).toBe(200);
123
144
  expect(response.json()).toEqual({ value: 80, error: null });
124
145
  await flushSettlement();
125
- expect(settlePayment).not.toHaveBeenCalled();
146
+ expect(capture).not.toHaveBeenCalled();
126
147
  });
127
148
  test("rejects an unknown function without settling the voucher", async () => {
128
149
  const response = await runResource({ functionName: "missing", paymentVoucher: voucherFor("missing") });
129
150
  expect(response.statusCode).toBe(400);
130
151
  expect(response.json()).toEqual({ error: "Function not found" });
131
152
  await flushSettlement();
132
- expect(settlePayment).not.toHaveBeenCalled();
153
+ expect(capture).not.toHaveBeenCalled();
133
154
  });
134
- test("does not settle when the invoke returns an error", async () => {
155
+ test("releases the hold when the invoke returns an error", async () => {
135
156
  vi.spyOn(service.resourceInvoker, "invoke").mockResolvedValueOnce({ value: null, error: { message: "boom" } });
136
- const response = await runResource({ paymentVoucher: voucherFor() });
157
+ const paymentVoucher = voucherFor();
158
+ const response = await runResource({ paymentVoucher });
137
159
  expect(response.statusCode).toBe(200);
138
160
  await flushSettlement();
139
- expect(settlePayment).not.toHaveBeenCalled();
161
+ expect(capture).not.toHaveBeenCalled();
162
+ expect(release).toHaveBeenCalledExactlyOnceWith(paymentVoucher);
140
163
  });
141
164
  test("rejects an unknown resource", async () => {
142
165
  const response = await runResource({ resourceName: "Missing" });
@@ -270,7 +293,7 @@ describe("routes", () => {
270
293
  parameters: [{ name: "city", type: "string" }],
271
294
  returnType: "string",
272
295
  exec: async () => "sunny",
273
- paymentManifest: { costMicroUsdc: 500, recipientAddress: RECIPIENT },
296
+ paymentManifest: { costMicroUsd: 500, recipientOrganizationId: ORGANIZATION },
274
297
  }],
275
298
  });
276
299
  function broadcast(resources, privateKey = keys.privateKey, publicKey = keys.publicKey) {
@@ -294,7 +317,7 @@ describe("routes", () => {
294
317
  const resources = (await fastify.inject({ method: "GET", url: "/v1/resources" })).json().resources;
295
318
  expect(resources.find((r) => r.name === "Forecast")).toMatchObject({
296
319
  baseUrl: peer.baseUrl,
297
- functions: [{ name: "tomorrow", paymentManifest: { costMicroUsdc: 500, recipientAddress: RECIPIENT } }],
320
+ functions: [{ name: "tomorrow", paymentManifest: { costMicroUsd: 500, recipientOrganizationId: ORGANIZATION } }],
298
321
  });
299
322
  });
300
323
  test("rejects a broadcast from an unknown peer", async () => {
@@ -366,7 +389,7 @@ describe("restricted functions", () => {
366
389
  name: "Vault",
367
390
  functions: [
368
391
  { name: "open", parameters: [{ name: "city", type: "string" }], returnType: "int32", exec: async () => 7, restricted: true },
369
- { name: "premium", parameters: [{ name: "city", type: "string" }], returnType: "int32", exec: async () => 9, restricted: true, paymentManifest: { costMicroUsdc: COST, recipientAddress: RECIPIENT } },
392
+ { name: "premium", parameters: [{ name: "city", type: "string" }], returnType: "int32", exec: async () => 9, restricted: true, paymentManifest: { costMicroUsd: COST, recipientOrganizationId: ORGANIZATION } },
370
393
  ],
371
394
  };
372
395
  const call = (functionName) => ({ resourcePath: "com.example", resourceName: "Vault", functionName, parameters });
@@ -379,7 +402,9 @@ describe("restricted functions", () => {
379
402
  beforeEach(async () => {
380
403
  db = await openDatabase(":memory:");
381
404
  service = createService(db, [restricted], testConfig());
382
- vi.spyOn(service.operatorClient, "settlePayment").mockResolvedValue(undefined);
405
+ vi.spyOn(service.operatorClient, "authorize").mockResolvedValue({ ok: true });
406
+ vi.spyOn(service.operatorClient, "capture").mockResolvedValue(undefined);
407
+ vi.spyOn(service.operatorClient, "release").mockResolvedValue(undefined);
383
408
  callerDb = await openDatabase(":memory:");
384
409
  callerGrants = new GrantService(new GrantRepository(callerDb), { baseUrl: "http://caller", ...caller });
385
410
  grant = await service.grantService.issue(caller.publicKey, [{ resourcePath: "com.example", resourceName: "Vault" }], { lifetimeMs: 3_600_000 });
@@ -491,11 +516,11 @@ describe("restricted functions", () => {
491
516
  expect(response.statusCode).toBe(403);
492
517
  });
493
518
  test("checks the authorization before the voucher, and needs both for a paid function", async () => {
494
- const paymentVoucher = callerInvoker.createPaymentVoucher(RECIPIENT, COST, "com.example", "Vault", "premium", parameters);
519
+ const paymentVoucher = callerInvoker.createPaymentVoucher(ORGANIZATION, COST, "com.example", "Vault", "premium", parameters);
495
520
  const unauthorized = await runResource({ ...call("premium"), paymentVoucher });
496
521
  expect(unauthorized.statusCode).toBe(403);
497
522
  await new Promise((resolve) => setTimeout(resolve, 10));
498
- expect(service.operatorClient.settlePayment).not.toHaveBeenCalled();
523
+ expect(service.operatorClient.authorize).not.toHaveBeenCalled();
499
524
  const unpaid = await runResource({ ...call("premium"), authorization: callerGrants.authorize(grant, call("premium")) });
500
525
  expect(unpaid.statusCode).toBe(402);
501
526
  const paid = await runResource({ ...call("premium"), paymentVoucher, authorization: callerGrants.authorize(grant, call("premium")) });
@@ -563,3 +588,149 @@ describe("restricted functions", () => {
563
588
  });
564
589
  });
565
590
  });
591
+ describe("inference functions", () => {
592
+ const weather = weatherResource();
593
+ const model = modelResource();
594
+ const caller = generateBase64KeyPair();
595
+ const callerInvoker = new CombinationResourceInvoker(undefined, 100_000, caller.publicKey, caller.privateKey);
596
+ // "Hello world!" is 3 input tokens by the fixture's estimator. 3 at $3/M plus 1,000 at $15/M, rounded up.
597
+ const parameters = [{ name: "prompt", value: "Hello world!" }, { name: "maxTokens", value: 1000 }];
598
+ const MAX_AMOUNT = 15_009;
599
+ const manifest = { ...INFERENCE_RATES, recipientOrganizationId: ORGANIZATION };
600
+ let db;
601
+ let service;
602
+ let fastify;
603
+ let authorize;
604
+ let capture;
605
+ let release;
606
+ beforeEach(async () => {
607
+ db = await openDatabase(":memory:");
608
+ service = createService(db, [weather, model], testConfig());
609
+ authorize = vi.spyOn(service.operatorClient, "authorize").mockResolvedValue({ ok: true });
610
+ capture = vi.spyOn(service.operatorClient, "capture").mockResolvedValue(undefined);
611
+ release = vi.spyOn(service.operatorClient, "release").mockResolvedValue(undefined);
612
+ fastify = Fastify();
613
+ await fastify.register(routes, { prefix: "/v1", service });
614
+ await fastify.ready();
615
+ });
616
+ afterEach(async () => {
617
+ await fastify.close();
618
+ await db.close();
619
+ vi.restoreAllMocks();
620
+ });
621
+ const flushSettlement = () => new Promise((resolve) => setTimeout(resolve, 10));
622
+ function voucherFor(functionName = "complete", overrides = {}) {
623
+ const voucher = callerInvoker.createInferenceVoucher(manifest, MAX_AMOUNT, 1000, model.path, model.name, functionName, parameters);
624
+ const resigned = { ...voucher, ...overrides };
625
+ return { ...resigned, signature: signData(inferenceVoucherPayload(resigned), caller.privateKey) };
626
+ }
627
+ function call(url, body = {}) {
628
+ return fastify.inject({
629
+ method: "POST",
630
+ url,
631
+ payload: { resourcePath: model.path, resourceName: model.name, functionName: "complete", parameters, ...body },
632
+ });
633
+ }
634
+ describe("POST /v1/run/resource/estimate", () => {
635
+ test("returns the estimator's input tokens and the maxAmount they imply", async () => {
636
+ const response = await call("/v1/run/resource/estimate");
637
+ expect(response.statusCode).toBe(200);
638
+ expect(response.json()).toEqual({ inputTokens: 3, maxAmount: MAX_AMOUNT });
639
+ });
640
+ test.each([
641
+ ["a free inference function", { functionName: "draft" }],
642
+ ["a fixed function", { resourceName: weather.name, functionName: "temperature", parameters: [{ name: "city", value: "Oslo" }] }],
643
+ ])("refuses %s", async (_, body) => {
644
+ const response = await call("/v1/run/resource/estimate", body);
645
+ expect(response.statusCode).toBe(400);
646
+ expect(response.json()).toEqual({ error: "Function is not a paid inference function" });
647
+ });
648
+ test("refuses a call without a positive maxTokens", async () => {
649
+ const response = await call("/v1/run/resource/estimate", { parameters: [{ name: "prompt", value: "Hi" }, { name: "maxTokens", value: 0 }] });
650
+ expect(response.statusCode).toBe(400);
651
+ });
652
+ test("requires authorization for a restricted function, before running the estimator", async () => {
653
+ const estimator = vi.spyOn(model.functions[1], "estimateInputTokens");
654
+ const response = await call("/v1/run/resource/estimate", { functionName: "completePrivate" });
655
+ expect(response.statusCode).toBe(403);
656
+ expect(estimator).not.toHaveBeenCalled();
657
+ });
658
+ test("answers 500 when the estimator throws", async () => {
659
+ vi.spyOn(model.functions[0], "estimateInputTokens").mockRejectedValue(new Error("token counter is down"));
660
+ const response = await call("/v1/run/resource/estimate");
661
+ expect(response.statusCode).toBe(500);
662
+ });
663
+ });
664
+ describe("POST /v1/run/resource", () => {
665
+ test("runs with a voucher covering the estimate and settles the reported usage", async () => {
666
+ const paymentVoucher = voucherFor();
667
+ const response = await call("/v1/run/resource", { paymentVoucher });
668
+ expect(response.statusCode).toBe(200);
669
+ expect(response.json()).toEqual({ value: "echo: Hello world!", error: null, usage: { inputTokens: 3, outputTokens: 1000 } });
670
+ await flushSettlement();
671
+ expect(capture).toHaveBeenCalledExactlyOnceWith({ voucher: paymentVoucher, usage: { inputTokens: 3, outputTokens: 1000 } });
672
+ });
673
+ test("refuses a voucher below the estimator's requirement without running inference", async () => {
674
+ const exec = vi.spyOn(model.functions[0], "exec");
675
+ const response = await call("/v1/run/resource", { paymentVoucher: voucherFor("complete", { maxAmount: MAX_AMOUNT - 1 }) });
676
+ expect(response.statusCode).toBe(402);
677
+ expect(response.json()).toMatchObject({ requiredMaxAmount: MAX_AMOUNT });
678
+ expect(exec).not.toHaveBeenCalled();
679
+ await flushSettlement();
680
+ expect(capture).not.toHaveBeenCalled();
681
+ });
682
+ test.each([
683
+ ["a different input rate", { inputCostMicroUsdPerMillionTokens: 1 }],
684
+ ["a different output rate", { outputCostMicroUsdPerMillionTokens: 1 }],
685
+ ["a maxTokens other than the call's", { maxTokens: 999 }],
686
+ ["a different recipient", { recipientOrganizationId: "org_other" }],
687
+ ])("rejects a voucher with %s", async (_, overrides) => {
688
+ const response = await call("/v1/run/resource", { paymentVoucher: voucherFor("complete", overrides) });
689
+ expect(response.statusCode).toBe(400);
690
+ expect(response.json()).toEqual({ error: "Invalid payment voucher" });
691
+ });
692
+ test("rejects a fixed voucher for an inference function, and an inference voucher for a fixed function", async () => {
693
+ const fixed = callerInvoker.createPaymentVoucher(ORGANIZATION, MAX_AMOUNT, model.path, model.name, "complete", parameters);
694
+ expect((await call("/v1/run/resource", { paymentVoucher: fixed })).statusCode).toBe(400);
695
+ const weatherParameters = [{ name: "city", value: "Oslo" }];
696
+ const inference = callerInvoker.createInferenceVoucher(manifest, MAX_AMOUNT, 1000, weather.path, weather.name, "temperature", weatherParameters);
697
+ const response = await call("/v1/run/resource", { resourceName: weather.name, functionName: "temperature", parameters: weatherParameters, paymentVoucher: inference });
698
+ expect(response.statusCode).toBe(400);
699
+ });
700
+ test("caps reported output at maxTokens", async () => {
701
+ vi.spyOn(model.functions[0], "exec").mockResolvedValue({ value: "long", usage: { inputTokens: 3, outputTokens: 5000 } });
702
+ const paymentVoucher = voucherFor();
703
+ const response = await call("/v1/run/resource", { paymentVoucher });
704
+ expect(response.json().usage).toEqual({ inputTokens: 3, outputTokens: 1000 });
705
+ await flushSettlement();
706
+ expect(capture).toHaveBeenCalledExactlyOnceWith({ voucher: paymentVoucher, usage: { inputTokens: 3, outputTokens: 1000 } });
707
+ });
708
+ test("releases the hold of a call that charges nothing", async () => {
709
+ vi.spyOn(model.functions[0], "exec").mockResolvedValue({ value: "", usage: { inputTokens: 0, outputTokens: 0 } });
710
+ const paymentVoucher = voucherFor();
711
+ const response = await call("/v1/run/resource", { paymentVoucher });
712
+ expect(response.statusCode).toBe(200);
713
+ await flushSettlement();
714
+ expect(capture).not.toHaveBeenCalled();
715
+ expect(release).toHaveBeenCalledExactlyOnceWith(paymentVoucher);
716
+ });
717
+ test("releases the hold when the function doesn't report usage", async () => {
718
+ vi.spyOn(model.functions[0], "exec").mockResolvedValue("no usage");
719
+ const paymentVoucher = voucherFor();
720
+ const response = await call("/v1/run/resource", { paymentVoucher });
721
+ expect(response.json().error.message).toContain("must return { value, usage");
722
+ await flushSettlement();
723
+ expect(capture).not.toHaveBeenCalled();
724
+ expect(release).toHaveBeenCalledExactlyOnceWith(paymentVoucher);
725
+ });
726
+ });
727
+ test("GET /v1/resources announces the kind and per-token pricing", async () => {
728
+ const response = await fastify.inject({ method: "GET", url: "/v1/resources" });
729
+ const functions = response.json().resources.find((resource) => resource.name === "Model").functions;
730
+ expect(functions[0]).toMatchObject({ name: "complete", kind: "inference", paymentManifest: manifest });
731
+ expect(functions[2]).toMatchObject({ name: "draft", kind: "inference" });
732
+ expect(functions[2].paymentManifest).toBeUndefined();
733
+ const temperature = response.json().resources.find((resource) => resource.name === "Weather").functions[0];
734
+ expect(temperature.kind).toBeUndefined();
735
+ });
736
+ });
@@ -1,6 +1,6 @@
1
1
  import { afterEach, describe, expect, test, vi } from "vitest";
2
- import { DEFAULT_COST_LIMIT_MICRO_USDC, resolveConfig } from "../server.js";
3
- import { weatherResource } from "./helpers.js";
2
+ import { DEFAULT_COST_LIMIT_MICRO_USD, resolveConfig } from "../server.js";
3
+ import { modelResource, weatherResource } from "./helpers.js";
4
4
  describe("resolveConfig", () => {
5
5
  const keys = { baseUrl: "http://node.example", publicKey: "public", privateKey: "private" };
6
6
  afterEach(() => {
@@ -15,15 +15,15 @@ describe("resolveConfig", () => {
15
15
  vi.stubEnv("BASE_URL", "http://env.example");
16
16
  vi.stubEnv("SIGNING_PUBLIC_KEY", "env-public");
17
17
  vi.stubEnv("SIGNING_PRIVATE_KEY", "env-private");
18
- vi.stubEnv("COST_LIMIT_MICRO_USDC", "500");
19
- expect(resolveConfig({})).toMatchObject({ baseUrl: "http://env.example", publicKey: "env-public", privateKey: "env-private", costLimitMicroUsdc: 500 });
18
+ vi.stubEnv("COST_LIMIT_MICRO_USD", "500");
19
+ expect(resolveConfig({})).toMatchObject({ baseUrl: "http://env.example", publicKey: "env-public", privateKey: "env-private", costLimitMicroUsd: 500 });
20
20
  });
21
21
  test("defaults the cost limit", () => {
22
- vi.stubEnv("COST_LIMIT_MICRO_USDC", undefined);
23
- expect(resolveConfig(keys).costLimitMicroUsdc).toBe(DEFAULT_COST_LIMIT_MICRO_USDC);
22
+ vi.stubEnv("COST_LIMIT_MICRO_USD", undefined);
23
+ expect(resolveConfig(keys).costLimitMicroUsd).toBe(DEFAULT_COST_LIMIT_MICRO_USD);
24
24
  });
25
- test.each([-1, 1.5, Number.NaN])("rejects a cost limit of %s", (costLimitMicroUsdc) => {
26
- expect(() => resolveConfig({ ...keys, costLimitMicroUsdc })).toThrow("costLimitMicroUsdc");
25
+ test.each([-1, 1.5, Number.NaN])("rejects a cost limit of %s", (costLimitMicroUsd) => {
26
+ expect(() => resolveConfig({ ...keys, costLimitMicroUsd })).toThrow("costLimitMicroUsd");
27
27
  });
28
28
  test("requires the signing keys and base URL", () => {
29
29
  vi.stubEnv("SIGNING_PRIVATE_KEY", undefined);
@@ -34,4 +34,31 @@ describe("resolveConfig", () => {
34
34
  expect(resolveConfig(keys, []).operatorUrl).toBeUndefined();
35
35
  expect(() => resolveConfig(keys, [weatherResource()])).toThrow("OPERATOR_URL is required");
36
36
  });
37
+ test("defaults the inference cost limit to the cost limit, and reads its own option or environment variable", () => {
38
+ vi.stubEnv("INFERENCE_COST_LIMIT_MICRO_USD", undefined);
39
+ expect(resolveConfig({ ...keys, costLimitMicroUsd: 700 }).inferenceCostLimitMicroUsd).toBe(700);
40
+ vi.stubEnv("INFERENCE_COST_LIMIT_MICRO_USD", "900");
41
+ expect(resolveConfig({ ...keys, costLimitMicroUsd: 700 }).inferenceCostLimitMicroUsd).toBe(900);
42
+ expect(resolveConfig({ ...keys, inferenceCostLimitMicroUsd: 1_000 }).inferenceCostLimitMicroUsd).toBe(1_000);
43
+ expect(() => resolveConfig({ ...keys, inferenceCostLimitMicroUsd: -1 })).toThrow("inferenceCostLimitMicroUsd");
44
+ });
45
+ test("accepts well-declared inference functions", () => {
46
+ expect(() => resolveConfig({ ...keys, operatorUrl: "http://operator.example" }, [modelResource()])).not.toThrow();
47
+ });
48
+ test.each([
49
+ ["without a maxTokens parameter", { parameters: [{ name: "prompt", type: "string" }] }, "maxTokens: int32"],
50
+ ["whose maxTokens isn't an int32", { parameters: [{ name: "maxTokens", type: "int64" }] }, "maxTokens: int32"],
51
+ ["that is paid but has no estimator", { estimateInputTokens: undefined }, "estimateInputTokens"],
52
+ ["priced with a flat cost", { paymentManifest: { costMicroUsd: 5, recipientOrganizationId: "r" } }, "per million tokens"],
53
+ ["with a negative rate", { paymentManifest: { inputCostMicroUsdPerMillionTokens: -1, outputCostMicroUsdPerMillionTokens: 0, recipientOrganizationId: "r" } }, "per million tokens"],
54
+ ])("rejects an inference function %s", (_, overrides, message) => {
55
+ const model = modelResource();
56
+ model.functions = [{ ...model.functions[0], ...overrides }];
57
+ expect(() => resolveConfig({ ...keys, operatorUrl: "http://operator.example" }, [model])).toThrow(message);
58
+ });
59
+ test("rejects per-token pricing on a function not declared as inference", () => {
60
+ const model = modelResource();
61
+ model.functions = [{ ...model.functions[0], kind: undefined }];
62
+ expect(() => resolveConfig({ ...keys, operatorUrl: "http://operator.example" }, [model])).toThrow("kind 'inference'");
63
+ });
37
64
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "stellarium-ts",
3
- "version": "0.15.0",
3
+ "version": "0.17.0",
4
4
  "description": "A Stellarium Node in TypeScript.",
5
5
  "homepage": "https://github.com/qualletio/stellarium-ts#readme",
6
6
  "bugs": {