stellarium-ts 0.14.0 → 0.16.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.
@@ -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, RECIPIENT, temperatureSource, weatherResource } from "../helpers.js";
10
+ import { COST, generateBase64KeyPair, INFERENCE_RATES, modelResource, RECIPIENT, 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 {
@@ -22,6 +22,7 @@ function vaultResource(baseUrl) {
22
22
  const vaultSource = 'request OpenVault {\n const vault: com.example.Vault\n\n return vault.open(city: "Oslo")\n}';
23
23
  const vault = { resourcePath: "com.example", resourceName: "Vault" };
24
24
  const HOUR = 3_600_000;
25
+ const completeSource = 'request Complete {\n const model: com.example.Model\n\n return model.complete(prompt: "Hello world!", maxTokens: 10)\n}';
25
26
  function freePort() {
26
27
  return new Promise((resolve, reject) => {
27
28
  const server = createServer();
@@ -36,9 +37,11 @@ function freePort() {
36
37
  * A provider node hosting the paid weather resource, a caller node that bootstraps from it,
37
38
  * and a fake operator that records settlements. Everything runs in-process over real HTTP.
38
39
  */
39
- describe("two-node network", () => {
40
+ // Each test starts real HTTP nodes on SQLite files, which can take several seconds on a loaded CI runner.
41
+ describe("two-node network", { timeout: 15_000 }, () => {
40
42
  const servers = [];
41
43
  const settlements = [];
44
+ const submissions = [];
42
45
  const callerKeys = generateBase64KeyPair();
43
46
  let dir;
44
47
  let operatorUrl;
@@ -72,10 +75,12 @@ describe("two-node network", () => {
72
75
  beforeEach(async () => {
73
76
  dir = await mkdtemp(path.join(tmpdir(), "stellarium-network-"));
74
77
  settlements.length = 0;
78
+ submissions.length = 0;
75
79
  const operator = Fastify();
76
80
  servers.push(operator);
77
81
  operator.post("/v1/payment", async (request) => {
78
82
  settlements.push(request.body.voucher);
83
+ submissions.push(request.body);
79
84
  return { status: "confirmed" };
80
85
  });
81
86
  operatorUrl = `${await operator.listen({ port: 0, host: "127.0.0.1" })}/v1`;
@@ -84,6 +89,7 @@ describe("two-node network", () => {
84
89
  provider = new StellariumNode();
85
90
  await provider.register(weatherResource(providerUrl));
86
91
  await provider.register(vaultResource(providerUrl));
92
+ await provider.register(modelResource(providerUrl));
87
93
  await startNode(provider, {
88
94
  port: providerPort,
89
95
  ...generateBase64KeyPair(),
@@ -106,7 +112,7 @@ describe("two-node network", () => {
106
112
  expect(callerResources.resources).toContainEqual(expect.objectContaining({
107
113
  name: "Weather",
108
114
  baseUrl: providerUrl,
109
- functions: expect.arrayContaining([expect.objectContaining({ name: "temperature", paymentManifest: { costLamports: COST, recipientAddress: RECIPIENT } })]),
115
+ functions: expect.arrayContaining([expect.objectContaining({ name: "temperature", paymentManifest: { costMicroUsdc: COST, recipientAddress: RECIPIENT } })]),
110
116
  }));
111
117
  expect(await run(caller.url)).toEqual({ returnValue: { value: 12, error: null } });
112
118
  await vi.waitFor(() => expect(settlements).toHaveLength(1));
@@ -179,10 +185,29 @@ describe("two-node network", () => {
179
185
  });
180
186
  });
181
187
  test("the caller refuses a call that costs more than its limit", async () => {
182
- const caller = await startCaller({ costLimitLamports: COST - 1 });
188
+ const caller = await startCaller({ costLimitMicroUsdc: COST - 1 });
183
189
  const result = await run(caller.url);
184
190
  expect(result.returnValue.value).toBeNull();
185
191
  expect(result.returnValue.error.message).toContain("exceeds the cost limit");
186
192
  expect(settlements).toEqual([]);
187
193
  });
194
+ describe("inference functions", () => {
195
+ test("the caller prices a call with the provider's estimate, pays for it, and the provider settles its usage", async () => {
196
+ const caller = await startCaller();
197
+ const result = await run(caller.url, completeSource);
198
+ expect(result).toEqual({ returnValue: { value: "echo: Hello world!", error: null } });
199
+ await vi.waitFor(() => expect(submissions).toHaveLength(1));
200
+ // 3 input tokens at $3/M plus 10 output tokens at $15/M, rounded up.
201
+ expect(submissions[0]).toMatchObject({
202
+ voucher: { kind: "inference", maxAmount: 159, maxTokens: 10, ...INFERENCE_RATES, recipientAddress: RECIPIENT, publicKey: callerKeys.publicKey },
203
+ usage: { inputTokens: 3, outputTokens: 10 },
204
+ });
205
+ });
206
+ test("the caller refuses a call whose maxAmount is over its inference cost limit", async () => {
207
+ const caller = await startCaller({ inferenceCostLimitMicroUsdc: 158 });
208
+ const result = await run(caller.url, completeSource);
209
+ expect(result.returnValue.error.message).toContain("exceeds the inference cost limit");
210
+ expect(submissions).toEqual([]);
211
+ });
212
+ });
188
213
  });
@@ -1,7 +1,8 @@
1
1
  import { generateKeyPairSync } from "node:crypto";
2
2
  import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
3
3
  import { verifyData } from "../../crypto/utils.js";
4
- import { CombinationResourceInvoker, EXTERNAL_INVOKE_MAX_RETRIES, invocationHash, paymentVoucherPayload } from "../../resource/invoker.js";
4
+ import { CombinationResourceInvoker, EXTERNAL_INVOKE_MAX_RETRIES, inferenceVoucherPayload, invocationHash, paymentVoucherPayload } from "../../resource/invoker.js";
5
+ import { inferenceCharge } from "../../resource/inference.js";
5
6
  function generateBase64KeyPair() {
6
7
  const { publicKey, privateKey } = generateKeyPairSync("ed25519", {
7
8
  publicKeyEncoding: { type: "spki", format: "der" },
@@ -93,7 +94,7 @@ describe("external invoke retries", () => {
93
94
  });
94
95
  test("reuses the same payment voucher on every attempt", async () => {
95
96
  fetchMock.mockResolvedValueOnce(status(503)).mockResolvedValueOnce(status(503)).mockResolvedValueOnce(ok());
96
- const invoker = createInvoker({ costLamports: 10000, recipientAddress: "4HkmXgXCJ8tw1ih5VFHetRt4kWdo1VLBVmV7B5p6ctK2" });
97
+ const invoker = createInvoker({ costMicroUsdc: 10000, recipientAddress: "4HkmXgXCJ8tw1ih5VFHetRt4kWdo1VLBVmV7B5p6ctK2" });
97
98
  const pending = invoker.invoke(resource, "forecast", parameters);
98
99
  await vi.runAllTimersAsync();
99
100
  await pending;
@@ -139,7 +140,7 @@ describe("restricted functions", () => {
139
140
  fetchMock.mockResolvedValueOnce(new Response(null, { status: 503 })).mockResolvedValueOnce(new Response(JSON.stringify({ value: 42, error: null }), { status: 200 }));
140
141
  let nonce = 0;
141
142
  const authorize = vi.fn(() => ({ nonce: `nonce-${nonce++}` }));
142
- const invoker = createInvoker({ chainFor: async () => chain, authorize }, { costLamports: 10000, recipientAddress: "4HkmXgXCJ8tw1ih5VFHetRt4kWdo1VLBVmV7B5p6ctK2" });
143
+ const invoker = createInvoker({ chainFor: async () => chain, authorize }, { costMicroUsdc: 10000, recipientAddress: "4HkmXgXCJ8tw1ih5VFHetRt4kWdo1VLBVmV7B5p6ctK2" });
143
144
  const pending = invoker.invoke(resource, "forecast", parameters);
144
145
  await vi.runAllTimersAsync();
145
146
  await expect(pending).resolves.toEqual({ value: 42, error: null });
@@ -149,3 +150,88 @@ describe("restricted functions", () => {
149
150
  expect(authorize).toHaveBeenCalledWith(chain, { resourcePath: "com.example", resourceName: "Weather", functionName: "forecast", parameters });
150
151
  });
151
152
  });
153
+ describe("inference functions", () => {
154
+ const RECIPIENT = "4HkmXgXCJ8tw1ih5VFHetRt4kWdo1VLBVmV7B5p6ctK2";
155
+ const resource = { path: "com.example", name: "Model", metadata: { baseUrl: "https://remote.example" }, functions: [] };
156
+ const parameters = [{ name: "prompt", value: "Hello" }, { name: "maxTokens", value: 1000 }];
157
+ const manifest = { inputCostMicroUsdcPerMillionTokens: 3_000_000, outputCostMicroUsdcPerMillionTokens: 15_000_000, recipientAddress: RECIPIENT };
158
+ // 2,000 input tokens and 1,000 output tokens: 6,000 + 15,000.
159
+ const MAX_AMOUNT = 21_000;
160
+ function createInvoker(inferenceCostLimit = 100_000, fn = { kind: "inference", paymentManifest: manifest }) {
161
+ const { publicKey, privateKey } = generateBase64KeyPair();
162
+ const repository = { getResourceFunction: async () => fn };
163
+ return { invoker: new CombinationResourceInvoker(repository, 100_000, publicKey, privateKey, undefined, inferenceCostLimit), publicKey };
164
+ }
165
+ const json = (body, status = 200) => new Response(JSON.stringify(body), { status });
166
+ let fetchMock;
167
+ beforeEach(() => {
168
+ fetchMock = vi.fn();
169
+ vi.stubGlobal("fetch", fetchMock);
170
+ });
171
+ afterEach(() => {
172
+ vi.unstubAllGlobals();
173
+ });
174
+ test("inferenceCharge rounds up per million tokens", () => {
175
+ 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();
179
+ });
180
+ test("asks the provider for an estimate, then signs a voucher for its maxAmount at the advertised rates", async () => {
181
+ fetchMock.mockResolvedValueOnce(json({ inputTokens: 2_000, maxAmount: MAX_AMOUNT })).mockResolvedValueOnce(json({ value: "Hi", error: null, usage: { inputTokens: 1_500, outputTokens: 10 } }));
182
+ const { invoker, publicKey } = createInvoker();
183
+ const result = await invoker.invoke(resource, "complete", parameters);
184
+ expect(result).toEqual({ value: "Hi", error: null });
185
+ const [[estimateUrl, estimateInit], [runUrl, runInit]] = fetchMock.mock.calls;
186
+ expect(estimateUrl).toBe("https://remote.example/v1/run/resource/estimate");
187
+ expect(JSON.parse(estimateInit.body)).toEqual({ resourcePath: "com.example", resourceName: "Model", functionName: "complete", parameters });
188
+ expect(runUrl).toBe("https://remote.example/v1/run/resource");
189
+ const voucher = JSON.parse(runInit.body).paymentVoucher;
190
+ expect(voucher).toMatchObject({ kind: "inference", maxAmount: MAX_AMOUNT, maxTokens: 1000, ...manifest, publicKey });
191
+ expect(voucher.amount).toBeUndefined();
192
+ expect(Object.keys(inferenceVoucherPayload(voucher))).toEqual(["kind", "requestId", "invocationId", "maxAmount", "inputCostMicroUsdcPerMillionTokens", "outputCostMicroUsdcPerMillionTokens", "maxTokens", "recipientAddress", "expiresAt", "invocationHash", "publicKey"]);
193
+ expect(verifyData(inferenceVoucherPayload(voucher), publicKey, voucher.signature)).toBe(true);
194
+ expect(Date.parse(voucher.expiresAt) - Date.now()).toBeGreaterThan(1000 * 60 * 5);
195
+ });
196
+ test("refuses an estimate whose maxAmount doesn't follow from the advertised rates", async () => {
197
+ fetchMock.mockResolvedValueOnce(json({ inputTokens: 2_000, maxAmount: MAX_AMOUNT + 1 }));
198
+ const result = await createInvoker().invoker.invoke(resource, "complete", parameters);
199
+ expect(result.error?.message).toContain("doesn't match its advertised rates");
200
+ expect(fetchMock).toHaveBeenCalledTimes(1);
201
+ });
202
+ test("refuses a maxAmount above the inference cost limit before signing", async () => {
203
+ fetchMock.mockResolvedValueOnce(json({ inputTokens: 2_000, maxAmount: MAX_AMOUNT }));
204
+ await expect(createInvoker(MAX_AMOUNT - 1).invoker.invoke(resource, "complete", parameters)).rejects.toThrow("inference cost limit");
205
+ expect(fetchMock).toHaveBeenCalledTimes(1);
206
+ });
207
+ test("fails before any network call without a positive int32 maxTokens", async () => {
208
+ const { invoker } = createInvoker();
209
+ for (const value of [undefined, 0, -1, 1.5, "10", 2 ** 31]) {
210
+ const call = value === undefined ? [parameters[0]] : [parameters[0], { name: "maxTokens", value }];
211
+ const result = await invoker.invoke(resource, "complete", call);
212
+ expect(result.error?.message).toContain("maxTokens");
213
+ }
214
+ expect(fetchMock).not.toHaveBeenCalled();
215
+ });
216
+ test("surfaces a failed estimate without signing a voucher", async () => {
217
+ fetchMock.mockResolvedValueOnce(json({ error: "Function is not a paid inference function" }, 400));
218
+ const result = await createInvoker().invoker.invoke(resource, "complete", parameters);
219
+ expect(result.error?.message).toContain("Function is not a paid inference function");
220
+ expect(fetchMock).toHaveBeenCalledTimes(1);
221
+ });
222
+ test("refuses per-token pricing on a function not declared as inference", async () => {
223
+ const result = await createInvoker(100_000, { paymentManifest: manifest }).invoker.invoke(resource, "complete", parameters);
224
+ expect(result.error?.message).toContain("isn't an inference function");
225
+ expect(fetchMock).not.toHaveBeenCalled();
226
+ });
227
+ test("returns a local inference function's value with its usage", async () => {
228
+ const local = {
229
+ path: "com.example", name: "Model", metadata: {},
230
+ functions: [{ name: "complete", kind: "inference", parameters: [], returnType: "string", exec: async () => ({ value: "Hi", usage: { inputTokens: 3, outputTokens: 1 } }) }],
231
+ };
232
+ const broken = { ...local, functions: [{ ...local.functions[0], exec: async () => "Hi" }] };
233
+ const { invoker } = createInvoker();
234
+ expect(await invoker.invoke(local, "complete", parameters)).toEqual({ value: "Hi", error: null, usage: { inputTokens: 3, outputTokens: 1 } });
235
+ expect((await invoker.invoke(broken, "complete", parameters)).error?.message).toContain("must return { value, usage");
236
+ });
237
+ });
@@ -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 = { inputCostMicroUsdcPerMillionTokens: 3_000_000, outputCostMicroUsdcPerMillionTokens: 0, recipientAddress: RECIPIENT };
56
+ await repository.learn(resource("Model", [
57
+ { name: "complete", kind: "inference", parameters: [{ name: "maxTokens", type: "int32" }], paymentManifest: pricing },
58
+ { name: "count", paymentManifest: { costMicroUsdc: 5, recipientAddress: RECIPIENT } },
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), costMicroUsdc: 5, recipientAddress: RECIPIENT });
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: { costLamports: 250, recipientAddress: RECIPIENT } },
105
+ { name: "paid", paymentManifest: { costMicroUsdc: 250, recipientAddress: RECIPIENT } },
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({ costLamports: 250, recipientAddress: RECIPIENT });
110
+ expect(found.functions.find(f => f.name === "paid")?.paymentManifest).toMatchObject({ costMicroUsdc: 250, recipientAddress: RECIPIENT });
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: { costLamports: 100, recipientAddress: RECIPIENT } },
136
+ { name: "today", parameters: [{ name: "city", type: "string" }], paymentManifest: { costMicroUsdc: 100, recipientAddress: RECIPIENT } },
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({ costLamports: 100 });
142
+ expect(today?.paymentManifest).toMatchObject({ costMicroUsdc: 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: { costLamports: 100, recipientAddress: RECIPIENT } }, { name: "old" }])]);
159
+ await save([resource("Forecast", [{ name: "today", paymentManifest: { costMicroUsdc: 100, recipientAddress: RECIPIENT } }, { 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, RECIPIENT, 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", () => {
@@ -270,7 +270,7 @@ describe("routes", () => {
270
270
  parameters: [{ name: "city", type: "string" }],
271
271
  returnType: "string",
272
272
  exec: async () => "sunny",
273
- paymentManifest: { costLamports: 500, recipientAddress: RECIPIENT },
273
+ paymentManifest: { costMicroUsdc: 500, recipientAddress: RECIPIENT },
274
274
  }],
275
275
  });
276
276
  function broadcast(resources, privateKey = keys.privateKey, publicKey = keys.publicKey) {
@@ -294,7 +294,7 @@ describe("routes", () => {
294
294
  const resources = (await fastify.inject({ method: "GET", url: "/v1/resources" })).json().resources;
295
295
  expect(resources.find((r) => r.name === "Forecast")).toMatchObject({
296
296
  baseUrl: peer.baseUrl,
297
- functions: [{ name: "tomorrow", paymentManifest: { costLamports: 500, recipientAddress: RECIPIENT } }],
297
+ functions: [{ name: "tomorrow", paymentManifest: { costMicroUsdc: 500, recipientAddress: RECIPIENT } }],
298
298
  });
299
299
  });
300
300
  test("rejects a broadcast from an unknown peer", async () => {
@@ -366,7 +366,7 @@ describe("restricted functions", () => {
366
366
  name: "Vault",
367
367
  functions: [
368
368
  { 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: { costLamports: COST, recipientAddress: RECIPIENT } },
369
+ { name: "premium", parameters: [{ name: "city", type: "string" }], returnType: "int32", exec: async () => 9, restricted: true, paymentManifest: { costMicroUsdc: COST, recipientAddress: RECIPIENT } },
370
370
  ],
371
371
  };
372
372
  const call = (functionName) => ({ resourcePath: "com.example", resourceName: "Vault", functionName, parameters });
@@ -563,3 +563,141 @@ describe("restricted functions", () => {
563
563
  });
564
564
  });
565
565
  });
566
+ describe("inference functions", () => {
567
+ const weather = weatherResource();
568
+ const model = modelResource();
569
+ const caller = generateBase64KeyPair();
570
+ const callerInvoker = new CombinationResourceInvoker(undefined, 100_000, caller.publicKey, caller.privateKey);
571
+ // "Hello world!" is 3 input tokens by the fixture's estimator. 3 at $3/M plus 1,000 at $15/M, rounded up.
572
+ const parameters = [{ name: "prompt", value: "Hello world!" }, { name: "maxTokens", value: 1000 }];
573
+ const MAX_AMOUNT = 15_009;
574
+ const manifest = { ...INFERENCE_RATES, recipientAddress: RECIPIENT };
575
+ let db;
576
+ let service;
577
+ let fastify;
578
+ let settlePayment;
579
+ beforeEach(async () => {
580
+ db = await openDatabase(":memory:");
581
+ service = createService(db, [weather, model], testConfig());
582
+ settlePayment = vi.spyOn(service.operatorClient, "settlePayment").mockResolvedValue(undefined);
583
+ fastify = Fastify();
584
+ await fastify.register(routes, { prefix: "/v1", service });
585
+ await fastify.ready();
586
+ });
587
+ afterEach(async () => {
588
+ await fastify.close();
589
+ await db.close();
590
+ vi.restoreAllMocks();
591
+ });
592
+ const flushSettlement = () => new Promise((resolve) => setTimeout(resolve, 10));
593
+ function voucherFor(functionName = "complete", overrides = {}) {
594
+ const voucher = callerInvoker.createInferenceVoucher(manifest, MAX_AMOUNT, 1000, model.path, model.name, functionName, parameters);
595
+ const resigned = { ...voucher, ...overrides };
596
+ return { ...resigned, signature: signData(inferenceVoucherPayload(resigned), caller.privateKey) };
597
+ }
598
+ function call(url, body = {}) {
599
+ return fastify.inject({
600
+ method: "POST",
601
+ url,
602
+ payload: { resourcePath: model.path, resourceName: model.name, functionName: "complete", parameters, ...body },
603
+ });
604
+ }
605
+ describe("POST /v1/run/resource/estimate", () => {
606
+ test("returns the estimator's input tokens and the maxAmount they imply", async () => {
607
+ const response = await call("/v1/run/resource/estimate");
608
+ expect(response.statusCode).toBe(200);
609
+ expect(response.json()).toEqual({ inputTokens: 3, maxAmount: MAX_AMOUNT });
610
+ });
611
+ test.each([
612
+ ["a free inference function", { functionName: "draft" }],
613
+ ["a fixed function", { resourceName: weather.name, functionName: "temperature", parameters: [{ name: "city", value: "Oslo" }] }],
614
+ ])("refuses %s", async (_, body) => {
615
+ const response = await call("/v1/run/resource/estimate", body);
616
+ expect(response.statusCode).toBe(400);
617
+ expect(response.json()).toEqual({ error: "Function is not a paid inference function" });
618
+ });
619
+ test("refuses a call without a positive maxTokens", async () => {
620
+ const response = await call("/v1/run/resource/estimate", { parameters: [{ name: "prompt", value: "Hi" }, { name: "maxTokens", value: 0 }] });
621
+ expect(response.statusCode).toBe(400);
622
+ });
623
+ test("requires authorization for a restricted function, before running the estimator", async () => {
624
+ const estimator = vi.spyOn(model.functions[1], "estimateInputTokens");
625
+ const response = await call("/v1/run/resource/estimate", { functionName: "completePrivate" });
626
+ expect(response.statusCode).toBe(403);
627
+ expect(estimator).not.toHaveBeenCalled();
628
+ });
629
+ test("answers 500 when the estimator throws", async () => {
630
+ vi.spyOn(model.functions[0], "estimateInputTokens").mockRejectedValue(new Error("token counter is down"));
631
+ const response = await call("/v1/run/resource/estimate");
632
+ expect(response.statusCode).toBe(500);
633
+ });
634
+ });
635
+ describe("POST /v1/run/resource", () => {
636
+ test("runs with a voucher covering the estimate and settles the reported usage", async () => {
637
+ const paymentVoucher = voucherFor();
638
+ const response = await call("/v1/run/resource", { paymentVoucher });
639
+ expect(response.statusCode).toBe(200);
640
+ expect(response.json()).toEqual({ value: "echo: Hello world!", error: null, usage: { inputTokens: 3, outputTokens: 1000 } });
641
+ await flushSettlement();
642
+ expect(settlePayment).toHaveBeenCalledExactlyOnceWith({ voucher: paymentVoucher, usage: { inputTokens: 3, outputTokens: 1000 } });
643
+ });
644
+ test("refuses a voucher below the estimator's requirement without running inference", async () => {
645
+ const exec = vi.spyOn(model.functions[0], "exec");
646
+ const response = await call("/v1/run/resource", { paymentVoucher: voucherFor("complete", { maxAmount: MAX_AMOUNT - 1 }) });
647
+ expect(response.statusCode).toBe(402);
648
+ expect(response.json()).toMatchObject({ requiredMaxAmount: MAX_AMOUNT });
649
+ expect(exec).not.toHaveBeenCalled();
650
+ await flushSettlement();
651
+ expect(settlePayment).not.toHaveBeenCalled();
652
+ });
653
+ test.each([
654
+ ["a different input rate", { inputCostMicroUsdcPerMillionTokens: 1 }],
655
+ ["a different output rate", { outputCostMicroUsdcPerMillionTokens: 1 }],
656
+ ["a maxTokens other than the call's", { maxTokens: 999 }],
657
+ ["a different recipient", { recipientAddress: "11111111111111111111111111111111" }],
658
+ ])("rejects a voucher with %s", async (_, overrides) => {
659
+ const response = await call("/v1/run/resource", { paymentVoucher: voucherFor("complete", overrides) });
660
+ expect(response.statusCode).toBe(400);
661
+ expect(response.json()).toEqual({ error: "Invalid payment voucher" });
662
+ });
663
+ test("rejects a fixed voucher for an inference function, and an inference voucher for a fixed function", async () => {
664
+ const fixed = callerInvoker.createPaymentVoucher(RECIPIENT, MAX_AMOUNT, model.path, model.name, "complete", parameters);
665
+ expect((await call("/v1/run/resource", { paymentVoucher: fixed })).statusCode).toBe(400);
666
+ const weatherParameters = [{ name: "city", value: "Oslo" }];
667
+ const inference = callerInvoker.createInferenceVoucher(manifest, MAX_AMOUNT, 1000, weather.path, weather.name, "temperature", weatherParameters);
668
+ const response = await call("/v1/run/resource", { resourceName: weather.name, functionName: "temperature", parameters: weatherParameters, paymentVoucher: inference });
669
+ expect(response.statusCode).toBe(400);
670
+ });
671
+ test("caps reported output at maxTokens", async () => {
672
+ vi.spyOn(model.functions[0], "exec").mockResolvedValue({ value: "long", usage: { inputTokens: 3, outputTokens: 5000 } });
673
+ const paymentVoucher = voucherFor();
674
+ const response = await call("/v1/run/resource", { paymentVoucher });
675
+ expect(response.json().usage).toEqual({ inputTokens: 3, outputTokens: 1000 });
676
+ await flushSettlement();
677
+ expect(settlePayment).toHaveBeenCalledExactlyOnceWith({ voucher: paymentVoucher, usage: { inputTokens: 3, outputTokens: 1000 } });
678
+ });
679
+ test("does not settle a call that charges nothing", async () => {
680
+ vi.spyOn(model.functions[0], "exec").mockResolvedValue({ value: "", usage: { inputTokens: 0, outputTokens: 0 } });
681
+ const response = await call("/v1/run/resource", { paymentVoucher: voucherFor() });
682
+ expect(response.statusCode).toBe(200);
683
+ await flushSettlement();
684
+ expect(settlePayment).not.toHaveBeenCalled();
685
+ });
686
+ test("does not settle when the function doesn't report usage", async () => {
687
+ vi.spyOn(model.functions[0], "exec").mockResolvedValue("no usage");
688
+ const response = await call("/v1/run/resource", { paymentVoucher: voucherFor() });
689
+ expect(response.json().error.message).toContain("must return { value, usage");
690
+ await flushSettlement();
691
+ expect(settlePayment).not.toHaveBeenCalled();
692
+ });
693
+ });
694
+ test("GET /v1/resources announces the kind and per-token pricing", async () => {
695
+ const response = await fastify.inject({ method: "GET", url: "/v1/resources" });
696
+ const functions = response.json().resources.find((resource) => resource.name === "Model").functions;
697
+ expect(functions[0]).toMatchObject({ name: "complete", kind: "inference", paymentManifest: manifest });
698
+ expect(functions[2]).toMatchObject({ name: "draft", kind: "inference" });
699
+ expect(functions[2].paymentManifest).toBeUndefined();
700
+ const temperature = response.json().resources.find((resource) => resource.name === "Weather").functions[0];
701
+ expect(temperature.kind).toBeUndefined();
702
+ });
703
+ });
@@ -1,6 +1,6 @@
1
1
  import { afterEach, describe, expect, test, vi } from "vitest";
2
- import { DEFAULT_COST_LIMIT_LAMPORTS, resolveConfig } from "../server.js";
3
- import { weatherResource } from "./helpers.js";
2
+ import { DEFAULT_COST_LIMIT_MICRO_USDC, 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_LAMPORTS", "500");
19
- expect(resolveConfig({})).toMatchObject({ baseUrl: "http://env.example", publicKey: "env-public", privateKey: "env-private", costLimitLamports: 500 });
18
+ vi.stubEnv("COST_LIMIT_MICRO_USDC", "500");
19
+ expect(resolveConfig({})).toMatchObject({ baseUrl: "http://env.example", publicKey: "env-public", privateKey: "env-private", costLimitMicroUsdc: 500 });
20
20
  });
21
21
  test("defaults the cost limit", () => {
22
- vi.stubEnv("COST_LIMIT_LAMPORTS", undefined);
23
- expect(resolveConfig(keys).costLimitLamports).toBe(DEFAULT_COST_LIMIT_LAMPORTS);
22
+ vi.stubEnv("COST_LIMIT_MICRO_USDC", undefined);
23
+ expect(resolveConfig(keys).costLimitMicroUsdc).toBe(DEFAULT_COST_LIMIT_MICRO_USDC);
24
24
  });
25
- test.each([-1, 1.5, Number.NaN])("rejects a cost limit of %s", (costLimitLamports) => {
26
- expect(() => resolveConfig({ ...keys, costLimitLamports })).toThrow("costLimitLamports");
25
+ test.each([-1, 1.5, Number.NaN])("rejects a cost limit of %s", (costLimitMicroUsdc) => {
26
+ expect(() => resolveConfig({ ...keys, costLimitMicroUsdc })).toThrow("costLimitMicroUsdc");
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_USDC", undefined);
39
+ expect(resolveConfig({ ...keys, costLimitMicroUsdc: 700 }).inferenceCostLimitMicroUsdc).toBe(700);
40
+ vi.stubEnv("INFERENCE_COST_LIMIT_MICRO_USDC", "900");
41
+ expect(resolveConfig({ ...keys, costLimitMicroUsdc: 700 }).inferenceCostLimitMicroUsdc).toBe(900);
42
+ expect(resolveConfig({ ...keys, inferenceCostLimitMicroUsdc: 1_000 }).inferenceCostLimitMicroUsdc).toBe(1_000);
43
+ expect(() => resolveConfig({ ...keys, inferenceCostLimitMicroUsdc: -1 })).toThrow("inferenceCostLimitMicroUsdc");
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: { costMicroUsdc: 5, recipientAddress: "r" } }, "per million tokens"],
53
+ ["with a negative rate", { paymentManifest: { inputCostMicroUsdcPerMillionTokens: -1, outputCostMicroUsdcPerMillionTokens: 0, recipientAddress: "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.14.0",
3
+ "version": "0.16.0",
4
4
  "description": "A Stellarium Node in TypeScript.",
5
5
  "homepage": "https://github.com/qualletio/stellarium-ts#readme",
6
6
  "bugs": {