stellarium-ts 0.15.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();
@@ -40,6 +41,7 @@ function freePort() {
40
41
  describe("two-node network", { timeout: 15_000 }, () => {
41
42
  const servers = [];
42
43
  const settlements = [];
44
+ const submissions = [];
43
45
  const callerKeys = generateBase64KeyPair();
44
46
  let dir;
45
47
  let operatorUrl;
@@ -73,10 +75,12 @@ describe("two-node network", { timeout: 15_000 }, () => {
73
75
  beforeEach(async () => {
74
76
  dir = await mkdtemp(path.join(tmpdir(), "stellarium-network-"));
75
77
  settlements.length = 0;
78
+ submissions.length = 0;
76
79
  const operator = Fastify();
77
80
  servers.push(operator);
78
81
  operator.post("/v1/payment", async (request) => {
79
82
  settlements.push(request.body.voucher);
83
+ submissions.push(request.body);
80
84
  return { status: "confirmed" };
81
85
  });
82
86
  operatorUrl = `${await operator.listen({ port: 0, host: "127.0.0.1" })}/v1`;
@@ -85,6 +89,7 @@ describe("two-node network", { timeout: 15_000 }, () => {
85
89
  provider = new StellariumNode();
86
90
  await provider.register(weatherResource(providerUrl));
87
91
  await provider.register(vaultResource(providerUrl));
92
+ await provider.register(modelResource(providerUrl));
88
93
  await startNode(provider, {
89
94
  port: providerPort,
90
95
  ...generateBase64KeyPair(),
@@ -186,4 +191,23 @@ describe("two-node network", { timeout: 15_000 }, () => {
186
191
  expect(result.returnValue.error.message).toContain("exceeds the cost limit");
187
192
  expect(settlements).toEqual([]);
188
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
+ });
189
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" },
@@ -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);
@@ -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", () => {
@@ -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
2
  import { DEFAULT_COST_LIMIT_MICRO_USDC, resolveConfig } from "../server.js";
3
- import { weatherResource } from "./helpers.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(() => {
@@ -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.15.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": {