stellarium-ts 0.16.0 → 0.18.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 (46) 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 +103 -31
  5. package/dist/client/operatorClient.d.ts.map +1 -1
  6. package/dist/client/operatorClient.js +107 -23
  7. package/dist/client/runTokens.d.ts +23 -0
  8. package/dist/client/runTokens.d.ts.map +1 -0
  9. package/dist/client/runTokens.js +34 -0
  10. package/dist/db/db.d.ts.map +1 -1
  11. package/dist/db/db.js +8 -5
  12. package/dist/index.d.ts +4 -2
  13. package/dist/index.d.ts.map +1 -1
  14. package/dist/index.js +4 -0
  15. package/dist/resource/inference.d.ts +5 -3
  16. package/dist/resource/inference.d.ts.map +1 -1
  17. package/dist/resource/inference.js +17 -7
  18. package/dist/resource/invoker.d.ts +48 -14
  19. package/dist/resource/invoker.d.ts.map +1 -1
  20. package/dist/resource/invoker.js +108 -38
  21. package/dist/resource/repository.d.ts +13 -7
  22. package/dist/resource/repository.d.ts.map +1 -1
  23. package/dist/resource/repository.js +23 -13
  24. package/dist/resource/settlement.d.ts +25 -0
  25. package/dist/resource/settlement.d.ts.map +1 -0
  26. package/dist/resource/settlement.js +59 -0
  27. package/dist/routes.d.ts +16 -0
  28. package/dist/routes.d.ts.map +1 -1
  29. package/dist/routes.js +103 -76
  30. package/dist/server.d.ts +11 -6
  31. package/dist/server.d.ts.map +1 -1
  32. package/dist/server.js +32 -7
  33. package/dist/service.d.ts +18 -6
  34. package/dist/service.d.ts.map +1 -1
  35. package/dist/service.js +8 -3
  36. package/dist/tests/client/operatorClient.test.js +94 -36
  37. package/dist/tests/db/db.test.js +4 -4
  38. package/dist/tests/helpers.d.ts +4 -3
  39. package/dist/tests/helpers.d.ts.map +1 -1
  40. package/dist/tests/helpers.js +8 -6
  41. package/dist/tests/integration/network.test.js +69 -18
  42. package/dist/tests/resource/invoker.test.js +33 -9
  43. package/dist/tests/resource/repository.test.js +9 -9
  44. package/dist/tests/routes.test.js +165 -40
  45. package/dist/tests/server.test.js +15 -15
  46. package/package.json +3 -2
@@ -1,39 +1,94 @@
1
1
  import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
2
- import { OperatorClient, SETTLE_PAYMENT_MAX_RETRIES } from "../../client/operatorClient.js";
3
- describe("settle payment retries", () => {
4
- const payment = {
5
- voucher: {
6
- requestId: "request-1",
7
- invocationId: "invocation-1",
8
- amount: 10000,
9
- recipientAddress: "4HkmXgXCJ8tw1ih5VFHetRt4kWdo1VLBVmV7B5p6ctK2",
10
- signature: "signature",
11
- expiresAt: "2030-01-01T00:00:00.000Z",
12
- invocationHash: "hash",
13
- publicKey: "public-key",
14
- },
15
- };
16
- const ok = () => new Response(JSON.stringify({ success: true }), { status: 200 });
17
- const status = (code, headers) => new Response(null, { status: code, headers });
18
- const networkError = (code) => new TypeError("fetch failed", { cause: Object.assign(new Error(code), { code }) });
19
- let fetchMock;
20
- beforeEach(() => {
21
- vi.useFakeTimers();
22
- vi.spyOn(console, "error").mockImplementation(() => { });
23
- fetchMock = vi.fn();
24
- vi.stubGlobal("fetch", fetchMock);
2
+ import { AUTHORIZE_MAX_RETRIES, OPERATOR_MAX_RETRIES, OperatorClient } from "../../client/operatorClient.js";
3
+ import { OPERATOR_AUTH_SCHEME, operatorRequestPayload } from "../../client/operatorAuth.js";
4
+ import { verifyData } from "../../crypto/utils.js";
5
+ import { generateBase64KeyPair } from "../helpers.js";
6
+ const keys = generateBase64KeyPair();
7
+ const client = () => new OperatorClient("https://operator.example/v1", keys.publicKey, keys.privateKey);
8
+ const payment = {
9
+ voucher: {
10
+ requestId: "request-1",
11
+ invocationId: "invocation-1",
12
+ amount: 10000,
13
+ recipientOrganizationId: "org_provider",
14
+ signature: "signature",
15
+ expiresAt: "2030-01-01T00:00:00.000Z",
16
+ invocationHash: "hash",
17
+ publicKey: "public-key",
18
+ },
19
+ };
20
+ const ok = (body = { success: true }) => new Response(JSON.stringify(body), { status: 200 });
21
+ const status = (code, headers) => new Response(null, { status: code, headers });
22
+ const refusal = (code, error) => new Response(JSON.stringify({ error }), { status: code });
23
+ const networkError = (code) => new TypeError("fetch failed", { cause: Object.assign(new Error(code), { code }) });
24
+ let fetchMock;
25
+ beforeEach(() => {
26
+ vi.useFakeTimers();
27
+ vi.spyOn(console, "error").mockImplementation(() => { });
28
+ fetchMock = vi.fn();
29
+ vi.stubGlobal("fetch", fetchMock);
30
+ });
31
+ afterEach(() => {
32
+ vi.useRealTimers();
33
+ vi.unstubAllGlobals();
34
+ vi.restoreAllMocks();
35
+ });
36
+ describe("request signing", () => {
37
+ test("signs the method, path, body hash and timestamp with the node key", async () => {
38
+ fetchMock.mockResolvedValue(ok());
39
+ await client().capture(payment);
40
+ const [url, init] = fetchMock.mock.calls[0];
41
+ expect(url).toBe("https://operator.example/v1/payments/capture");
42
+ const [scheme, encoded] = init.headers.Authorization.split(" ");
43
+ expect(scheme).toBe(OPERATOR_AUTH_SCHEME);
44
+ const credentials = JSON.parse(Buffer.from(encoded, "base64url").toString());
45
+ expect(credentials.publicKey).toBe(keys.publicKey);
46
+ const payload = operatorRequestPayload("POST", "/v1/payments/capture", JSON.parse(init.body), credentials.timestamp);
47
+ expect(verifyData(payload, keys.publicKey, credentials.signature)).toBe(true);
48
+ });
49
+ });
50
+ describe("session", () => {
51
+ test("returns the node's organization, sending the enrollment token when given", async () => {
52
+ fetchMock.mockResolvedValue(ok({ organizationId: "org_provider" }));
53
+ await expect(client().session("enroll-1")).resolves.toEqual({ organizationId: "org_provider" });
54
+ expect(JSON.parse(fetchMock.mock.calls[0][1].body)).toEqual({ enrollmentToken: "enroll-1" });
25
55
  });
26
- afterEach(() => {
27
- vi.useRealTimers();
28
- vi.unstubAllGlobals();
29
- vi.restoreAllMocks();
56
+ test("throws the operator's refusal", async () => {
57
+ fetchMock.mockResolvedValue(refusal(403, "Node is not enrolled in an organization"));
58
+ await expect(client().session()).rejects.toThrow("Node is not enrolled in an organization");
59
+ });
60
+ });
61
+ describe("authorize", () => {
62
+ test("reports a hold as ok", async () => {
63
+ fetchMock.mockResolvedValue(ok());
64
+ await expect(client().authorize(payment.voucher)).resolves.toEqual({ ok: true });
65
+ });
66
+ test.each([[402, "Insufficient balance"], [403, "Payer organization is frozen"], [422, "Recipient organization can't receive payouts"]])("passes a %i refusal through without retrying", async (code, error) => {
67
+ fetchMock.mockResolvedValue(refusal(code, error));
68
+ await expect(client().authorize(payment.voucher)).resolves.toEqual({ ok: false, status: code, error });
69
+ expect(fetchMock).toHaveBeenCalledTimes(1);
30
70
  });
71
+ test("reports a persistent server error as 503 after its few retries", async () => {
72
+ fetchMock.mockResolvedValue(status(500));
73
+ const pending = client().authorize(payment.voucher);
74
+ await vi.runAllTimersAsync();
75
+ await expect(pending).resolves.toMatchObject({ ok: false, status: 503 });
76
+ expect(fetchMock).toHaveBeenCalledTimes(1 + AUTHORIZE_MAX_RETRIES);
77
+ });
78
+ test("reports an unreachable operator as 503", async () => {
79
+ fetchMock.mockRejectedValue(networkError("ECONNREFUSED"));
80
+ const pending = client().authorize(payment.voucher);
81
+ await vi.runAllTimersAsync();
82
+ await expect(pending).resolves.toEqual({ ok: false, status: 503, error: "Payment operator is unavailable" });
83
+ });
84
+ });
85
+ describe("capture and release retries", () => {
31
86
  test("retries 429 responses with exponential backoff, then succeeds", async () => {
32
87
  fetchMock
33
88
  .mockResolvedValueOnce(status(429))
34
89
  .mockResolvedValueOnce(status(429))
35
90
  .mockResolvedValueOnce(ok());
36
- const pending = new OperatorClient("https://operator.example/v1").settlePayment(payment);
91
+ const pending = client().capture(payment);
37
92
  await vi.advanceTimersByTimeAsync(0);
38
93
  expect(fetchMock).toHaveBeenCalledTimes(1);
39
94
  await vi.advanceTimersByTimeAsync(999);
@@ -49,7 +104,7 @@ describe("settle payment retries", () => {
49
104
  });
50
105
  test("waits for Retry-After when it is longer than the backoff", async () => {
51
106
  fetchMock.mockResolvedValueOnce(status(429, { "retry-after": "30" })).mockResolvedValueOnce(ok());
52
- const pending = new OperatorClient("https://operator.example/v1").settlePayment(payment);
107
+ const pending = client().release(payment.voucher);
53
108
  await vi.advanceTimersByTimeAsync(29_999);
54
109
  expect(fetchMock).toHaveBeenCalledTimes(1);
55
110
  await vi.advanceTimersByTimeAsync(1);
@@ -58,7 +113,7 @@ describe("settle payment retries", () => {
58
113
  });
59
114
  test.each([408, 500, 502, 503, 504])("retries %i responses", async (code) => {
60
115
  fetchMock.mockResolvedValueOnce(status(code)).mockResolvedValueOnce(ok());
61
- const pending = new OperatorClient("https://operator.example/v1").settlePayment(payment);
116
+ const pending = client().capture(payment);
62
117
  await vi.runAllTimersAsync();
63
118
  await pending;
64
119
  expect(fetchMock).toHaveBeenCalledTimes(2);
@@ -66,31 +121,34 @@ describe("settle payment retries", () => {
66
121
  });
67
122
  test.each([400, 401, 404, 409, 422])("does not retry %i responses", async (code) => {
68
123
  fetchMock.mockResolvedValue(status(code));
69
- await new OperatorClient("https://operator.example/v1").settlePayment(payment);
124
+ await client().capture(payment);
70
125
  expect(fetchMock).toHaveBeenCalledTimes(1);
71
126
  expect(console.error).toHaveBeenCalledOnce();
72
127
  });
73
128
  test.each(["ECONNREFUSED", "ECONNRESET"])("retries %s network errors", async (code) => {
74
129
  fetchMock.mockRejectedValueOnce(networkError(code)).mockResolvedValueOnce(ok());
75
- const pending = new OperatorClient("https://operator.example/v1").settlePayment(payment);
130
+ const pending = client().capture(payment);
76
131
  await vi.runAllTimersAsync();
77
132
  await pending;
78
133
  expect(fetchMock).toHaveBeenCalledTimes(2);
79
134
  });
80
135
  test("rethrows the last network error once retries are exhausted", async () => {
81
136
  fetchMock.mockRejectedValue(networkError("ECONNREFUSED"));
82
- const pending = new OperatorClient("https://operator.example/v1").settlePayment(payment);
137
+ const pending = client().capture(payment);
83
138
  const assertion = expect(pending).rejects.toThrow("fetch failed");
84
139
  await vi.runAllTimersAsync();
85
140
  await assertion;
86
- expect(fetchMock).toHaveBeenCalledTimes(1 + SETTLE_PAYMENT_MAX_RETRIES);
141
+ expect(fetchMock).toHaveBeenCalledTimes(1 + OPERATOR_MAX_RETRIES);
87
142
  });
88
143
  test("gives up after the maximum number of retries", async () => {
89
144
  fetchMock.mockResolvedValue(status(429));
90
- const pending = new OperatorClient("https://operator.example/v1").settlePayment(payment);
145
+ const pending = client().capture(payment);
91
146
  await vi.runAllTimersAsync();
92
147
  await pending;
93
- expect(fetchMock).toHaveBeenCalledTimes(1 + SETTLE_PAYMENT_MAX_RETRIES);
148
+ expect(fetchMock).toHaveBeenCalledTimes(1 + OPERATOR_MAX_RETRIES);
94
149
  expect(console.error).toHaveBeenCalledOnce();
95
150
  });
151
+ test("throws when no operator URL is configured", async () => {
152
+ await expect(new OperatorClient(undefined, keys.publicKey, keys.privateKey).capture(payment)).rejects.toThrow("No operator URL is configured");
153
+ });
96
154
  });
@@ -17,7 +17,7 @@ describe("openDatabase", () => {
17
17
  const filename = path.join(dir, "node.db");
18
18
  await (await openDatabase(filename)).close();
19
19
  const db = await openDatabase(filename);
20
- expect(await db.get("PRAGMA user_version")).toEqual({ user_version: 5 });
20
+ expect(await db.get("PRAGMA user_version")).toEqual({ user_version: 6 });
21
21
  await db.close();
22
22
  });
23
23
  test("rebuilds resource tables from the old schema and keeps peers", async () => {
@@ -61,7 +61,7 @@ describe("openDatabase", () => {
61
61
  expect(await reopened.all("SELECT id FROM held_grant")).toEqual([{ id: "g" }]);
62
62
  await reopened.close();
63
63
  });
64
- test("rebuilds a version 4 manifest table so inference functions can omit cost_micro_usdc", async () => {
64
+ test("rebuilds a version 4 manifest table into the micro-USD, organization-recipient schema", async () => {
65
65
  const filename = path.join(dir, "v4.db");
66
66
  const old = await open({ filename, driver: sqlite3.Database });
67
67
  await old.exec(`
@@ -74,8 +74,8 @@ describe("openDatabase", () => {
74
74
  const db = await openDatabase(filename);
75
75
  const functionColumns = await db.all("PRAGMA table_info(resource_function)");
76
76
  expect(functionColumns.map(column => column.name)).toContain("kind");
77
- await db.run(`INSERT INTO resource_function_payment_manifest (resource_function_id, input_cost_micro_usdc_per_million_tokens,
78
- output_cost_micro_usdc_per_million_tokens, recipient_address, created_at, updated_at) VALUES (1, 3, 15, 'r', 'now', 'now')`);
77
+ await db.run(`INSERT INTO resource_function_payment_manifest (resource_function_id, input_cost_micro_usd_per_million_tokens,
78
+ output_cost_micro_usd_per_million_tokens, recipient_organization_id, created_at, updated_at) VALUES (1, 3, 15, 'r', 'now', 'now')`);
79
79
  await db.close();
80
80
  });
81
81
  });
@@ -4,14 +4,15 @@ export declare function generateBase64KeyPair(): {
4
4
  publicKey: string;
5
5
  privateKey: string;
6
6
  };
7
- export declare const RECIPIENT = "4HkmXgXCJ8tw1ih5VFHetRt4kWdo1VLBVmV7B5p6ctK2";
7
+ /** The operator organization the fixture provider is enrolled in. */
8
+ export declare const ORGANIZATION = "org_provider";
8
9
  export declare const COST = 10000;
9
10
  export declare function testConfig(overrides?: Partial<NodeConfig>): NodeConfig;
10
11
  /** The fixture provider's resource: paid `temperature` and free `humidity`. */
11
12
  export declare function weatherResource(baseUrl?: string): StellariumResource;
12
13
  export declare const INFERENCE_RATES: {
13
- inputCostMicroUsdcPerMillionTokens: number;
14
- outputCostMicroUsdcPerMillionTokens: number;
14
+ inputCostMicroUsdPerMillionTokens: number;
15
+ outputCostMicroUsdPerMillionTokens: number;
15
16
  };
16
17
  /**
17
18
  * A fixture provider's model: paid inference `complete`, which counts one token per four prompt characters
@@ -1 +1 @@
1
- {"version":3,"file":"helpers.d.ts","sourceRoot":"","sources":["../../src/tests/helpers.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,2BAA2B,CAAC;AACpE,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,eAAe,CAAC;AAEhD,wBAAgB,qBAAqB;IAM7B,SAAS;IACT,UAAU;EAEjB;AAED,eAAO,MAAM,SAAS,iDAAiD,CAAC;AACxE,eAAO,MAAM,IAAI,QAAQ,CAAC;AAE1B,wBAAgB,UAAU,CAAC,SAAS,GAAE,OAAO,CAAC,UAAU,CAAM,GAAG,UAAU,CAU1E;AAED,+EAA+E;AAC/E,wBAAgB,eAAe,CAAC,OAAO,SAA0B,GAAG,kBAAkB,CAuBrF;AAED,eAAO,MAAM,eAAe;IAAK,kCAAkC;IAAa,mCAAmC;CAAc,CAAC;AAElI;;;GAGG;AACH,wBAAgB,aAAa,CAAC,OAAO,SAA0B,GAAG,kBAAkB,CA0BnF;AAED,eAAO,MAAM,iBAAiB,sHAAoH,CAAC"}
1
+ {"version":3,"file":"helpers.d.ts","sourceRoot":"","sources":["../../src/tests/helpers.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,2BAA2B,CAAC;AACpE,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,eAAe,CAAC;AAEhD,wBAAgB,qBAAqB;IAM7B,SAAS;IACT,UAAU;EAEjB;AAED,qEAAqE;AACrE,eAAO,MAAM,YAAY,iBAAiB,CAAC;AAC3C,eAAO,MAAM,IAAI,QAAQ,CAAC;AAE1B,wBAAgB,UAAU,CAAC,SAAS,GAAE,OAAO,CAAC,UAAU,CAAM,GAAG,UAAU,CAW1E;AAED,+EAA+E;AAC/E,wBAAgB,eAAe,CAAC,OAAO,SAA0B,GAAG,kBAAkB,CAuBrF;AAED,eAAO,MAAM,eAAe;IAAK,iCAAiC;IAAa,kCAAkC;CAAc,CAAC;AAEhI;;;GAGG;AACH,wBAAgB,aAAa,CAAC,OAAO,SAA0B,GAAG,kBAAkB,CA0BnF;AAED,eAAO,MAAM,iBAAiB,sHAAoH,CAAC"}
@@ -9,7 +9,8 @@ export function generateBase64KeyPair() {
9
9
  privateKey: privateKey.toString("base64url"),
10
10
  };
11
11
  }
12
- export const RECIPIENT = "4HkmXgXCJ8tw1ih5VFHetRt4kWdo1VLBVmV7B5p6ctK2";
12
+ /** The operator organization the fixture provider is enrolled in. */
13
+ export const ORGANIZATION = "org_provider";
13
14
  export const COST = 10000;
14
15
  export function testConfig(overrides = {}) {
15
16
  return {
@@ -17,8 +18,9 @@ export function testConfig(overrides = {}) {
17
18
  ...generateBase64KeyPair(),
18
19
  apiKey: "secret",
19
20
  operatorUrl: "http://operator.invalid/v1",
20
- costLimitMicroUsdc: 100_000,
21
- inferenceCostLimitMicroUsdc: 100_000,
21
+ organizationId: ORGANIZATION,
22
+ costLimitMicroUsd: 100_000,
23
+ inferenceCostLimitMicroUsd: 100_000,
22
24
  ...overrides,
23
25
  };
24
26
  }
@@ -36,7 +38,7 @@ export function weatherResource(baseUrl = "http://127.0.0.1:3002") {
36
38
  parameters: [{ name: "city", type: "string" }],
37
39
  returnType: "int32",
38
40
  exec: async (args) => (args.find((arg) => arg.name === "city")?.value === "Oslo" ? 12 : 20),
39
- paymentManifest: { costMicroUsdc: COST, recipientAddress: RECIPIENT },
41
+ paymentManifest: { costMicroUsd: COST },
40
42
  },
41
43
  {
42
44
  name: "humidity",
@@ -47,7 +49,7 @@ export function weatherResource(baseUrl = "http://127.0.0.1:3002") {
47
49
  ],
48
50
  };
49
51
  }
50
- export const INFERENCE_RATES = { inputCostMicroUsdcPerMillionTokens: 3_000_000, outputCostMicroUsdcPerMillionTokens: 15_000_000 };
52
+ export const INFERENCE_RATES = { inputCostMicroUsdPerMillionTokens: 3_000_000, outputCostMicroUsdPerMillionTokens: 15_000_000 };
51
53
  /**
52
54
  * A fixture provider's model: paid inference `complete`, which counts one token per four prompt characters
53
55
  * and answers with `maxTokens` tokens, a restricted paid copy `completePrivate`, and free inference `draft`.
@@ -64,7 +66,7 @@ export function modelResource(baseUrl = "http://127.0.0.1:3002") {
64
66
  value: `echo: ${prompt(args)}`,
65
67
  usage: { inputTokens: Math.ceil(prompt(args).length / 4), outputTokens: Number(args.find((arg) => arg.name === "maxTokens")?.value) },
66
68
  }),
67
- paymentManifest: { ...INFERENCE_RATES, recipientAddress: RECIPIENT },
69
+ paymentManifest: { ...INFERENCE_RATES },
68
70
  };
69
71
  return {
70
72
  id: 2,
@@ -7,7 +7,7 @@ import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
7
7
  import { verifyData } from "../../crypto/utils.js";
8
8
  import { paymentVoucherPayload } from "../../resource/invoker.js";
9
9
  import { StellariumNode } from "../../server.js";
10
- import { COST, generateBase64KeyPair, INFERENCE_RATES, modelResource, RECIPIENT, temperatureSource, weatherResource } from "../helpers.js";
10
+ import { COST, generateBase64KeyPair, INFERENCE_RATES, modelResource, ORGANIZATION, temperatureSource, weatherResource } from "../helpers.js";
11
11
  /** A free function only callers with a grant from the provider may run. */
12
12
  function vaultResource(baseUrl) {
13
13
  return {
@@ -35,13 +35,18 @@ function freePort() {
35
35
  }
36
36
  /**
37
37
  * A provider node hosting the paid weather resource, a caller node that bootstraps from it,
38
- * and a fake operator that records settlements. Everything runs in-process over real HTTP.
38
+ * and a fake operator that enrolls the provider and records holds and captures. Everything runs in-process over real HTTP.
39
39
  */
40
40
  // Each test starts real HTTP nodes on SQLite files, which can take several seconds on a loaded CI runner.
41
41
  describe("two-node network", { timeout: 15_000 }, () => {
42
42
  const servers = [];
43
43
  const settlements = [];
44
44
  const submissions = [];
45
+ const authorizations = [];
46
+ const releases = [];
47
+ const sessions = [];
48
+ /** What the fake operator answers to the next authorizations; undefined places the hold. */
49
+ let refuseAuthorization;
45
50
  const callerKeys = generateBase64KeyPair();
46
51
  let dir;
47
52
  let operatorUrl;
@@ -76,12 +81,34 @@ describe("two-node network", { timeout: 15_000 }, () => {
76
81
  dir = await mkdtemp(path.join(tmpdir(), "stellarium-network-"));
77
82
  settlements.length = 0;
78
83
  submissions.length = 0;
84
+ authorizations.length = 0;
85
+ releases.length = 0;
86
+ sessions.length = 0;
87
+ refuseAuthorization = undefined;
79
88
  const operator = Fastify();
80
89
  servers.push(operator);
81
- operator.post("/v1/payment", async (request) => {
90
+ operator.post("/v1/nodes/session", async (request, reply) => {
91
+ if (!String(request.headers.authorization).startsWith("Stellarium ")) {
92
+ return reply.status(401).send({ error: "Unsigned request" });
93
+ }
94
+ sessions.push(request.body);
95
+ return { organizationId: ORGANIZATION };
96
+ });
97
+ operator.post("/v1/payments/authorize", async (request, reply) => {
98
+ authorizations.push(request.body);
99
+ if (refuseAuthorization) {
100
+ return reply.status(refuseAuthorization.status).send({ error: refuseAuthorization.error });
101
+ }
102
+ return { status: "held" };
103
+ });
104
+ operator.post("/v1/payments/capture", async (request) => {
82
105
  settlements.push(request.body.voucher);
83
106
  submissions.push(request.body);
84
- return { status: "confirmed" };
107
+ return { status: "captured" };
108
+ });
109
+ operator.post("/v1/payments/release", async (request) => {
110
+ releases.push(request.body);
111
+ return { status: "released" };
85
112
  });
86
113
  operatorUrl = `${await operator.listen({ port: 0, host: "127.0.0.1" })}/v1`;
87
114
  const providerPort = await freePort();
@@ -112,19 +139,20 @@ describe("two-node network", { timeout: 15_000 }, () => {
112
139
  expect(callerResources.resources).toContainEqual(expect.objectContaining({
113
140
  name: "Weather",
114
141
  baseUrl: providerUrl,
115
- functions: expect.arrayContaining([expect.objectContaining({ name: "temperature", paymentManifest: { costMicroUsdc: COST, recipientAddress: RECIPIENT } })]),
142
+ functions: expect.arrayContaining([expect.objectContaining({ name: "temperature", paymentManifest: { costMicroUsd: COST, recipientOrganizationId: ORGANIZATION } })]),
116
143
  }));
117
- expect(await run(caller.url)).toEqual({ returnValue: { value: 12, error: null } });
144
+ expect(await run(caller.url)).toEqual({ usage: { tokens: expect.any(Number) }, returnValue: { value: 12, error: null } });
118
145
  await vi.waitFor(() => expect(settlements).toHaveLength(1));
119
146
  const [voucher] = settlements;
120
- expect(voucher).toMatchObject({ amount: COST, recipientAddress: RECIPIENT, publicKey: callerKeys.publicKey });
147
+ expect(authorizations).toEqual([{ voucher }]);
148
+ expect(voucher).toMatchObject({ amount: COST, recipientOrganizationId: ORGANIZATION, publicKey: callerKeys.publicKey });
121
149
  expect(verifyData(paymentVoucherPayload(voucher), voucher.publicKey, voucher.signature)).toBe(true);
122
150
  });
123
151
  test("the caller restarts on the same database without failing", async () => {
124
152
  const first = await startCaller();
125
153
  await first.fastify.close();
126
154
  const second = await startCaller();
127
- expect(await run(second.url)).toEqual({ returnValue: { value: 12, error: null } });
155
+ expect(await run(second.url)).toEqual({ usage: { tokens: expect.any(Number) }, returnValue: { value: 12, error: null } });
128
156
  });
129
157
  describe("restricted functions", () => {
130
158
  test("the caller needs a grant from the provider, then calls with it", async () => {
@@ -132,13 +160,13 @@ describe("two-node network", { timeout: 15_000 }, () => {
132
160
  const refused = await run(caller.url, vaultSource);
133
161
  expect(refused.returnValue.error.message).toContain(`requires a grant from ${providerUrl}`);
134
162
  await caller.node.addGrant(await provider.issueGrant(callerKeys.publicKey, [vault], { lifetimeMs: HOUR }));
135
- expect(await run(caller.url, vaultSource)).toEqual({ returnValue: { value: 7, error: null } });
163
+ expect(await run(caller.url, vaultSource)).toEqual({ usage: { tokens: expect.any(Number) }, returnValue: { value: 7, error: null } });
136
164
  });
137
165
  test("the caller asks the provider for a grant, then calls with it", async () => {
138
166
  const caller = await startCaller();
139
167
  await expect(caller.node.requestGrant(providerUrl, [vault], { invite: "wrong" })).rejects.toThrow("Grant request refused");
140
168
  await caller.node.requestGrant(providerUrl, [vault], { invite: "letmein" });
141
- expect(await run(caller.url, vaultSource)).toEqual({ returnValue: { value: 7, error: null } });
169
+ expect(await run(caller.url, vaultSource)).toEqual({ usage: { tokens: expect.any(Number) }, returnValue: { value: 7, error: null } });
142
170
  });
143
171
  test("the provider's admin blocks a caller over HTTP, then unblocks it", async () => {
144
172
  const caller = await startCaller();
@@ -154,12 +182,12 @@ describe("two-node network", { timeout: 15_000 }, () => {
154
182
  expect(refused.returnValue.error.message).toContain("Invalid authorization");
155
183
  await expect(caller.node.requestGrant(providerUrl, [vault], { invite: "letmein" })).rejects.toThrow("refused");
156
184
  expect((await adminRequest("DELETE")).status).toBe(200);
157
- expect(await run(caller.url, vaultSource)).toEqual({ returnValue: { value: 7, error: null } });
185
+ expect(await run(caller.url, vaultSource)).toEqual({ usage: { tokens: expect.any(Number) }, returnValue: { value: 7, error: null } });
158
186
  });
159
187
  test("grants passed at start are loaded, and revocation at the provider cuts them off", async () => {
160
188
  const chain = await provider.issueGrant(callerKeys.publicKey, [vault], { lifetimeMs: HOUR });
161
189
  const caller = await startCaller({ grants: [chain] });
162
- expect(await run(caller.url, vaultSource)).toEqual({ returnValue: { value: 7, error: null } });
190
+ expect(await run(caller.url, vaultSource)).toEqual({ usage: { tokens: expect.any(Number) }, returnValue: { value: 7, error: null } });
163
191
  await provider.revokeGrant(chain[0].id);
164
192
  const refused = await run(caller.url, vaultSource);
165
193
  expect(refused.returnValue.error.message).toContain("Invalid authorization");
@@ -171,7 +199,7 @@ describe("two-node network", { timeout: 15_000 }, () => {
171
199
  const userKeys = generateBase64KeyPair();
172
200
  const user = await startCaller({ ...userKeys, databasePath: path.join(dir, "user.db") });
173
201
  await user.node.addGrant(await caller.node.delegateGrant(root.id, userKeys.publicKey, [vault], { lifetimeMs: HOUR }));
174
- expect(await run(user.url, vaultSource)).toEqual({ returnValue: { value: 7, error: null } });
202
+ expect(await run(user.url, vaultSource)).toEqual({ usage: { tokens: expect.any(Number) }, returnValue: { value: 7, error: null } });
175
203
  });
176
204
  test("the refresher renews a grant past half its lifetime", async () => {
177
205
  const fetchSpy = vi.spyOn(globalThis, "fetch");
@@ -181,30 +209,53 @@ describe("two-node network", { timeout: 15_000 }, () => {
181
209
  fetchSpy.mockRestore();
182
210
  // Well past the first copy's expiry, the renewed one still works.
183
211
  await new Promise((resolve) => setTimeout(resolve, 500));
184
- expect(await run(caller.url, vaultSource)).toEqual({ returnValue: { value: 7, error: null } });
212
+ expect(await run(caller.url, vaultSource)).toEqual({ usage: { tokens: expect.any(Number) }, returnValue: { value: 7, error: null } });
185
213
  });
186
214
  });
187
215
  test("the caller refuses a call that costs more than its limit", async () => {
188
- const caller = await startCaller({ costLimitMicroUsdc: COST - 1 });
216
+ const caller = await startCaller({ costLimitMicroUsd: COST - 1 });
189
217
  const result = await run(caller.url);
190
218
  expect(result.returnValue.value).toBeNull();
191
219
  expect(result.returnValue.error.message).toContain("exceeds the cost limit");
192
220
  expect(settlements).toEqual([]);
221
+ expect(authorizations).toEqual([]);
222
+ });
223
+ test("a caller with an enrollment token enrolls at startup; one without still reports its URL", async () => {
224
+ sessions.length = 0;
225
+ const enrolled = await startCaller({ operatorUrl, enrollmentToken: "enr_caller" });
226
+ expect(sessions).toEqual([{ enrollmentToken: "enr_caller", baseUrl: enrolled.url }]);
227
+ sessions.length = 0;
228
+ const other = await startCaller({ operatorUrl, databasePath: path.join(dir, "caller-2.db") });
229
+ await vi.waitFor(() => expect(sessions).toEqual([{ baseUrl: other.url }]));
230
+ });
231
+ test("a node without paid functions or a token starts without waiting for an unreachable operator", async () => {
232
+ const caller = await startCaller({ operatorUrl: "http://127.0.0.1:1/v1", databasePath: path.join(dir, "caller-3.db") });
233
+ expect(await run(caller.url)).toMatchObject({ returnValue: { value: 12, error: null } });
234
+ });
235
+ test("the provider refuses to run a call the operator won't hold, and the caller sees why", async () => {
236
+ const caller = await startCaller();
237
+ refuseAuthorization = { status: 402, error: "Insufficient balance" };
238
+ const result = await run(caller.url);
239
+ expect(result.returnValue.value).toBeNull();
240
+ expect(result.returnValue.error.message).toContain("Insufficient balance");
241
+ expect(authorizations).toHaveLength(1);
242
+ expect(settlements).toEqual([]);
243
+ expect(releases).toEqual([]);
193
244
  });
194
245
  describe("inference functions", () => {
195
246
  test("the caller prices a call with the provider's estimate, pays for it, and the provider settles its usage", async () => {
196
247
  const caller = await startCaller();
197
248
  const result = await run(caller.url, completeSource);
198
- expect(result).toEqual({ returnValue: { value: "echo: Hello world!", error: null } });
249
+ expect(result).toEqual({ usage: { tokens: expect.any(Number) }, returnValue: { value: "echo: Hello world!", error: null } });
199
250
  await vi.waitFor(() => expect(submissions).toHaveLength(1));
200
251
  // 3 input tokens at $3/M plus 10 output tokens at $15/M, rounded up.
201
252
  expect(submissions[0]).toMatchObject({
202
- voucher: { kind: "inference", maxAmount: 159, maxTokens: 10, ...INFERENCE_RATES, recipientAddress: RECIPIENT, publicKey: callerKeys.publicKey },
253
+ voucher: { kind: "inference", maxAmount: 159, maxTokens: 10, ...INFERENCE_RATES, recipientOrganizationId: ORGANIZATION, publicKey: callerKeys.publicKey },
203
254
  usage: { inputTokens: 3, outputTokens: 10 },
204
255
  });
205
256
  });
206
257
  test("the caller refuses a call whose maxAmount is over its inference cost limit", async () => {
207
- const caller = await startCaller({ inferenceCostLimitMicroUsdc: 158 });
258
+ const caller = await startCaller({ inferenceCostLimitMicroUsd: 158 });
208
259
  const result = await run(caller.url, completeSource);
209
260
  expect(result.returnValue.error.message).toContain("exceeds the inference cost limit");
210
261
  expect(submissions).toEqual([]);
@@ -18,11 +18,23 @@ describe("payment voucher", () => {
18
18
  const { publicKey, privateKey } = generateBase64KeyPair();
19
19
  const invoker = new CombinationResourceInvoker({}, 100_000, publicKey, privateKey);
20
20
  const parameters = [{ name: "city", value: "Oslo" }];
21
- const voucher = invoker.createPaymentVoucher("4HkmXgXCJ8tw1ih5VFHetRt4kWdo1VLBVmV7B5p6ctK2", 10000, "com.example", "Weather", "forecast", parameters);
21
+ const voucher = invoker.createPaymentVoucher("org_provider", 10000, "com.example", "Weather", "forecast", parameters);
22
22
  expect(voucher.invocationHash).toBe(invocationHash("com.example", "Weather", "forecast", parameters));
23
23
  expect(verifyData(paymentVoucherPayload(voucher), voucher.publicKey, voucher.signature)).toBe(true);
24
24
  expect(verifyData(voucher, voucher.publicKey, voucher.signature)).toBe(false);
25
25
  });
26
+ test("signs a run id last, and only when the voucher has one", () => {
27
+ const { publicKey, privateKey } = generateBase64KeyPair();
28
+ const invoker = new CombinationResourceInvoker({}, 100_000, publicKey, privateKey);
29
+ const parameters = [{ name: "city", value: "Oslo" }];
30
+ const plain = invoker.createPaymentVoucher("org_provider", 10000, "com.example", "Weather", "forecast", parameters);
31
+ const forRun = invoker.createPaymentVoucher("org_provider", 10000, "com.example", "Weather", "forecast", parameters, "run-1");
32
+ // Vouchers without a run sign the same bytes as before run ids existed.
33
+ expect(Object.keys(paymentVoucherPayload(plain))).toEqual(["requestId", "invocationId", "amount", "recipientOrganizationId", "expiresAt", "invocationHash", "publicKey"]);
34
+ expect(Object.keys(paymentVoucherPayload(forRun)).at(-1)).toBe("runId");
35
+ expect(verifyData(paymentVoucherPayload(forRun), publicKey, forRun.signature)).toBe(true);
36
+ expect(verifyData(paymentVoucherPayload({ ...forRun, runId: "run-2" }), publicKey, forRun.signature)).toBe(false);
37
+ });
26
38
  test("invocationHash changes with the function name or parameters", () => {
27
39
  const parameters = [{ name: "city", value: "Oslo" }];
28
40
  const hash = invocationHash("com.example", "Weather", "forecast", parameters);
@@ -94,7 +106,7 @@ describe("external invoke retries", () => {
94
106
  });
95
107
  test("reuses the same payment voucher on every attempt", async () => {
96
108
  fetchMock.mockResolvedValueOnce(status(503)).mockResolvedValueOnce(status(503)).mockResolvedValueOnce(ok());
97
- const invoker = createInvoker({ costMicroUsdc: 10000, recipientAddress: "4HkmXgXCJ8tw1ih5VFHetRt4kWdo1VLBVmV7B5p6ctK2" });
109
+ const invoker = createInvoker({ costMicroUsd: 10000, recipientOrganizationId: "org_provider" });
98
110
  const pending = invoker.invoke(resource, "forecast", parameters);
99
111
  await vi.runAllTimersAsync();
100
112
  await pending;
@@ -104,6 +116,18 @@ describe("external invoke retries", () => {
104
116
  expect(JSON.parse(bodies[0]).paymentVoucher.requestId).toBeDefined();
105
117
  expect(JSON.parse(bodies[0]).paymentVoucher.invocationHash).toBe(invocationHash("com.example", "Weather", "forecast", parameters));
106
118
  });
119
+ test("a billed run's calls carry its run id, and its hold replaces the cost limit", async () => {
120
+ fetchMock.mockResolvedValueOnce(ok());
121
+ const invoker = createInvoker({ costMicroUsd: 500_000, recipientOrganizationId: "org_provider" });
122
+ await invoker.forRun({ runId: "run-1" }).invoke(resource, "forecast", parameters);
123
+ expect(JSON.parse(fetchMock.mock.calls[0][1].body).paymentVoucher).toMatchObject({ amount: 500_000, runId: "run-1" });
124
+ await expect(invoker.invoke(resource, "forecast", parameters)).rejects.toThrow("exceeds the cost limit");
125
+ });
126
+ test("refuses a paid function whose manifest names no recipient organization, without signing a voucher", async () => {
127
+ const result = await createInvoker({ costMicroUsd: 10000 }).invoke(resource, "forecast", parameters);
128
+ expect(fetchMock).not.toHaveBeenCalled();
129
+ expect(result.error?.message).toContain("names no recipient organization");
130
+ });
107
131
  });
108
132
  describe("restricted functions", () => {
109
133
  const resource = { path: "com.example", name: "Weather", metadata: { baseUrl: "https://remote.example" }, functions: [] };
@@ -140,7 +164,7 @@ describe("restricted functions", () => {
140
164
  fetchMock.mockResolvedValueOnce(new Response(null, { status: 503 })).mockResolvedValueOnce(new Response(JSON.stringify({ value: 42, error: null }), { status: 200 }));
141
165
  let nonce = 0;
142
166
  const authorize = vi.fn(() => ({ nonce: `nonce-${nonce++}` }));
143
- const invoker = createInvoker({ chainFor: async () => chain, authorize }, { costMicroUsdc: 10000, recipientAddress: "4HkmXgXCJ8tw1ih5VFHetRt4kWdo1VLBVmV7B5p6ctK2" });
167
+ const invoker = createInvoker({ chainFor: async () => chain, authorize }, { costMicroUsd: 10000, recipientOrganizationId: "org_provider" });
144
168
  const pending = invoker.invoke(resource, "forecast", parameters);
145
169
  await vi.runAllTimersAsync();
146
170
  await expect(pending).resolves.toEqual({ value: 42, error: null });
@@ -151,10 +175,10 @@ describe("restricted functions", () => {
151
175
  });
152
176
  });
153
177
  describe("inference functions", () => {
154
- const RECIPIENT = "4HkmXgXCJ8tw1ih5VFHetRt4kWdo1VLBVmV7B5p6ctK2";
178
+ const ORGANIZATION = "org_provider";
155
179
  const resource = { path: "com.example", name: "Model", metadata: { baseUrl: "https://remote.example" }, functions: [] };
156
180
  const parameters = [{ name: "prompt", value: "Hello" }, { name: "maxTokens", value: 1000 }];
157
- const manifest = { inputCostMicroUsdcPerMillionTokens: 3_000_000, outputCostMicroUsdcPerMillionTokens: 15_000_000, recipientAddress: RECIPIENT };
181
+ const manifest = { inputCostMicroUsdPerMillionTokens: 3_000_000, outputCostMicroUsdPerMillionTokens: 15_000_000, recipientOrganizationId: ORGANIZATION };
158
182
  // 2,000 input tokens and 1,000 output tokens: 6,000 + 15,000.
159
183
  const MAX_AMOUNT = 21_000;
160
184
  function createInvoker(inferenceCostLimit = 100_000, fn = { kind: "inference", paymentManifest: manifest }) {
@@ -173,9 +197,9 @@ describe("inference functions", () => {
173
197
  });
174
198
  test("inferenceCharge rounds up per million tokens", () => {
175
199
  expect(inferenceCharge(manifest, 2_000, 1_000)).toBe(21_000);
176
- expect(inferenceCharge({ inputCostMicroUsdcPerMillionTokens: 150_000, outputCostMicroUsdcPerMillionTokens: 0 }, 1, 1_000_000)).toBe(1);
177
- expect(inferenceCharge({ inputCostMicroUsdcPerMillionTokens: 0, outputCostMicroUsdcPerMillionTokens: 0 }, 5, 5)).toBe(0);
178
- expect(() => inferenceCharge({ inputCostMicroUsdcPerMillionTokens: Number.MAX_SAFE_INTEGER, outputCostMicroUsdcPerMillionTokens: 0 }, Number.MAX_SAFE_INTEGER, 0)).toThrow();
200
+ expect(inferenceCharge({ inputCostMicroUsdPerMillionTokens: 150_000, outputCostMicroUsdPerMillionTokens: 0 }, 1, 1_000_000)).toBe(1);
201
+ expect(inferenceCharge({ inputCostMicroUsdPerMillionTokens: 0, outputCostMicroUsdPerMillionTokens: 0 }, 5, 5)).toBe(0);
202
+ expect(() => inferenceCharge({ inputCostMicroUsdPerMillionTokens: Number.MAX_SAFE_INTEGER, outputCostMicroUsdPerMillionTokens: 0 }, Number.MAX_SAFE_INTEGER, 0)).toThrow();
179
203
  });
180
204
  test("asks the provider for an estimate, then signs a voucher for its maxAmount at the advertised rates", async () => {
181
205
  fetchMock.mockResolvedValueOnce(json({ inputTokens: 2_000, maxAmount: MAX_AMOUNT })).mockResolvedValueOnce(json({ value: "Hi", error: null, usage: { inputTokens: 1_500, outputTokens: 10 } }));
@@ -189,7 +213,7 @@ describe("inference functions", () => {
189
213
  const voucher = JSON.parse(runInit.body).paymentVoucher;
190
214
  expect(voucher).toMatchObject({ kind: "inference", maxAmount: MAX_AMOUNT, maxTokens: 1000, ...manifest, publicKey });
191
215
  expect(voucher.amount).toBeUndefined();
192
- expect(Object.keys(inferenceVoucherPayload(voucher))).toEqual(["kind", "requestId", "invocationId", "maxAmount", "inputCostMicroUsdcPerMillionTokens", "outputCostMicroUsdcPerMillionTokens", "maxTokens", "recipientAddress", "expiresAt", "invocationHash", "publicKey"]);
216
+ expect(Object.keys(inferenceVoucherPayload(voucher))).toEqual(["kind", "requestId", "invocationId", "maxAmount", "inputCostMicroUsdPerMillionTokens", "outputCostMicroUsdPerMillionTokens", "maxTokens", "recipientOrganizationId", "expiresAt", "invocationHash", "publicKey"]);
193
217
  expect(verifyData(inferenceVoucherPayload(voucher), publicKey, voucher.signature)).toBe(true);
194
218
  expect(Date.parse(voucher.expiresAt) - Date.now()).toBeGreaterThan(1000 * 60 * 5);
195
219
  });