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.
- package/dist/client/operatorAuth.d.ts +19 -0
- package/dist/client/operatorAuth.d.ts.map +1 -0
- package/dist/client/operatorAuth.js +21 -0
- package/dist/client/operatorClient.d.ts +103 -31
- package/dist/client/operatorClient.d.ts.map +1 -1
- package/dist/client/operatorClient.js +107 -23
- package/dist/client/runTokens.d.ts +23 -0
- package/dist/client/runTokens.d.ts.map +1 -0
- package/dist/client/runTokens.js +34 -0
- package/dist/db/db.d.ts.map +1 -1
- package/dist/db/db.js +8 -5
- package/dist/index.d.ts +4 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -0
- package/dist/resource/inference.d.ts +5 -3
- package/dist/resource/inference.d.ts.map +1 -1
- package/dist/resource/inference.js +17 -7
- package/dist/resource/invoker.d.ts +48 -14
- package/dist/resource/invoker.d.ts.map +1 -1
- package/dist/resource/invoker.js +108 -38
- package/dist/resource/repository.d.ts +13 -7
- package/dist/resource/repository.d.ts.map +1 -1
- package/dist/resource/repository.js +23 -13
- package/dist/resource/settlement.d.ts +25 -0
- package/dist/resource/settlement.d.ts.map +1 -0
- package/dist/resource/settlement.js +59 -0
- package/dist/routes.d.ts +16 -0
- package/dist/routes.d.ts.map +1 -1
- package/dist/routes.js +103 -76
- package/dist/server.d.ts +11 -6
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +32 -7
- package/dist/service.d.ts +18 -6
- package/dist/service.d.ts.map +1 -1
- package/dist/service.js +8 -3
- package/dist/tests/client/operatorClient.test.js +94 -36
- package/dist/tests/db/db.test.js +4 -4
- package/dist/tests/helpers.d.ts +4 -3
- package/dist/tests/helpers.d.ts.map +1 -1
- package/dist/tests/helpers.js +8 -6
- package/dist/tests/integration/network.test.js +69 -18
- package/dist/tests/resource/invoker.test.js +33 -9
- package/dist/tests/resource/repository.test.js +9 -9
- package/dist/tests/routes.test.js +165 -40
- package/dist/tests/server.test.js +15 -15
- package/package.json +3 -2
|
@@ -3,7 +3,7 @@ import { signData } from "../../crypto/utils.js";
|
|
|
3
3
|
import { openDatabase } from "../../db/db.js";
|
|
4
4
|
import { PeerRepository } from "../../peer/repository.js";
|
|
5
5
|
import { resourceAnnouncementPayload, ResourceRepository } from "../../resource/repository.js";
|
|
6
|
-
import { generateBase64KeyPair,
|
|
6
|
+
import { generateBase64KeyPair, ORGANIZATION, weatherResource } from "../helpers.js";
|
|
7
7
|
const SELF = "http://self.example";
|
|
8
8
|
const PROVIDER = "http://provider.example";
|
|
9
9
|
function resource(name, functions, baseUrl = PROVIDER) {
|
|
@@ -52,16 +52,16 @@ describe("ResourceRepository", () => {
|
|
|
52
52
|
expect((await repository.getResourceFunction("com.provider", "Forecast", "tomorrow"))?.description).toBe("Tomorrow's forecast");
|
|
53
53
|
});
|
|
54
54
|
test("keeps inference functions' kind and per-token pricing", async () => {
|
|
55
|
-
const pricing = {
|
|
55
|
+
const pricing = { inputCostMicroUsdPerMillionTokens: 3_000_000, outputCostMicroUsdPerMillionTokens: 0, recipientOrganizationId: ORGANIZATION };
|
|
56
56
|
await repository.learn(resource("Model", [
|
|
57
57
|
{ name: "complete", kind: "inference", parameters: [{ name: "maxTokens", type: "int32" }], paymentManifest: pricing },
|
|
58
|
-
{ name: "count", paymentManifest: {
|
|
58
|
+
{ name: "count", paymentManifest: { costMicroUsd: 5, recipientOrganizationId: ORGANIZATION } },
|
|
59
59
|
]));
|
|
60
60
|
const complete = await repository.getResourceFunction("com.provider", "Model", "complete");
|
|
61
61
|
expect(complete).toMatchObject({ kind: "inference", paymentManifest: pricing });
|
|
62
62
|
const count = await repository.getResourceFunction("com.provider", "Model", "count");
|
|
63
63
|
expect(count?.kind).toBeUndefined();
|
|
64
|
-
expect(count?.paymentManifest).toEqual({ id: expect.any(Number),
|
|
64
|
+
expect(count?.paymentManifest).toEqual({ id: expect.any(Number), costMicroUsd: 5, recipientOrganizationId: ORGANIZATION });
|
|
65
65
|
const functions = (await repository.getResource("com.provider.Model"))?.functions;
|
|
66
66
|
expect(functions.find(fn => fn.name === "complete")).toMatchObject({ kind: "inference", paymentManifest: pricing });
|
|
67
67
|
});
|
|
@@ -102,12 +102,12 @@ describe("ResourceRepository", () => {
|
|
|
102
102
|
test("returns each function's own payment manifest", async () => {
|
|
103
103
|
await repository.learn(resource("Forecast", [
|
|
104
104
|
{ name: "free" },
|
|
105
|
-
{ name: "paid", paymentManifest: {
|
|
105
|
+
{ name: "paid", paymentManifest: { costMicroUsd: 250, recipientOrganizationId: ORGANIZATION } },
|
|
106
106
|
]));
|
|
107
107
|
const found = await repository.getResource("com.provider.Forecast");
|
|
108
108
|
expect(found.metadata).toEqual({ baseUrl: PROVIDER });
|
|
109
109
|
expect(found.functions.find(f => f.name === "free")?.paymentManifest).toBeUndefined();
|
|
110
|
-
expect(found.functions.find(f => f.name === "paid")?.paymentManifest).toMatchObject({
|
|
110
|
+
expect(found.functions.find(f => f.name === "paid")?.paymentManifest).toMatchObject({ costMicroUsd: 250, recipientOrganizationId: ORGANIZATION });
|
|
111
111
|
});
|
|
112
112
|
test("prefers the internal resource", async () => {
|
|
113
113
|
const found = await repository.getResource("com.example.Weather");
|
|
@@ -133,13 +133,13 @@ describe("ResourceRepository", () => {
|
|
|
133
133
|
test("adds new functions, parameters and payment manifests to a known resource", async () => {
|
|
134
134
|
await save([resource("Forecast", [{ name: "today" }])]);
|
|
135
135
|
const result = await save([resource("Forecast", [
|
|
136
|
-
{ name: "today", parameters: [{ name: "city", type: "string" }], paymentManifest: {
|
|
136
|
+
{ name: "today", parameters: [{ name: "city", type: "string" }], paymentManifest: { costMicroUsd: 100, recipientOrganizationId: ORGANIZATION } },
|
|
137
137
|
{ name: "tomorrow" },
|
|
138
138
|
])]);
|
|
139
139
|
expect(result).toEqual({ ok: true, rejected: [] });
|
|
140
140
|
const today = await repository.getResourceFunction("com.provider", "Forecast", "today");
|
|
141
141
|
expect(today?.parameters).toEqual([{ name: "city", type: "string" }]);
|
|
142
|
-
expect(today?.paymentManifest).toMatchObject({
|
|
142
|
+
expect(today?.paymentManifest).toMatchObject({ costMicroUsd: 100 });
|
|
143
143
|
expect(await repository.getResourceFunction("com.provider", "Forecast", "tomorrow")).toBeDefined();
|
|
144
144
|
});
|
|
145
145
|
test("drops descriptions that aren't strings and websites that aren't http(s) URLs", async () => {
|
|
@@ -156,7 +156,7 @@ describe("ResourceRepository", () => {
|
|
|
156
156
|
expect([radar.website, radar.description]).toEqual(["https://radar.example", "Rain radar"]);
|
|
157
157
|
});
|
|
158
158
|
test("removes functions and payment manifests the peer no longer advertises", async () => {
|
|
159
|
-
await save([resource("Forecast", [{ name: "today", paymentManifest: {
|
|
159
|
+
await save([resource("Forecast", [{ name: "today", paymentManifest: { costMicroUsd: 100, recipientOrganizationId: ORGANIZATION } }, { name: "old" }])]);
|
|
160
160
|
await save([resource("Forecast", [{ name: "today" }])]);
|
|
161
161
|
expect((await repository.getResourceFunction("com.provider", "Forecast", "today"))?.paymentManifest).toBeUndefined();
|
|
162
162
|
expect(await repository.getResourceFunction("com.provider", "Forecast", "old")).toBeUndefined();
|
|
@@ -1,6 +1,8 @@
|
|
|
1
|
+
import { generateKeyPairSync } from "node:crypto";
|
|
1
2
|
import Fastify from "fastify";
|
|
3
|
+
import { createLocalJWKSet, exportJWK, SignJWT } from "jose";
|
|
2
4
|
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
|
3
|
-
import { signData } from "../crypto/utils.js";
|
|
5
|
+
import { safeVerifyData, signData } from "../crypto/utils.js";
|
|
4
6
|
import { openDatabase } from "../db/db.js";
|
|
5
7
|
import { grantRenewalPayload, grantRequestPayload } from "../grant/grant.js";
|
|
6
8
|
import { GrantRepository } from "../grant/repository.js";
|
|
@@ -10,7 +12,7 @@ import { CombinationResourceInvoker, inferenceVoucherPayload, paymentVoucherPayl
|
|
|
10
12
|
import { resourceAnnouncementPayload } from "../resource/repository.js";
|
|
11
13
|
import routes from "../routes.js";
|
|
12
14
|
import { createService } from "../service.js";
|
|
13
|
-
import { COST, generateBase64KeyPair, INFERENCE_RATES, modelResource,
|
|
15
|
+
import { COST, generateBase64KeyPair, INFERENCE_RATES, modelResource, ORGANIZATION, temperatureSource, testConfig, weatherResource } from "./helpers.js";
|
|
14
16
|
const parameters = [{ name: "city", value: "Oslo" }];
|
|
15
17
|
const inMinutes = (minutes) => new Date(Date.now() + minutes * 60_000).toISOString();
|
|
16
18
|
describe("routes", () => {
|
|
@@ -22,12 +24,16 @@ describe("routes", () => {
|
|
|
22
24
|
let config;
|
|
23
25
|
let service;
|
|
24
26
|
let fastify;
|
|
25
|
-
let
|
|
27
|
+
let authorize;
|
|
28
|
+
let capture;
|
|
29
|
+
let release;
|
|
26
30
|
beforeEach(async () => {
|
|
27
31
|
db = await openDatabase(":memory:");
|
|
28
32
|
config = testConfig();
|
|
29
33
|
service = createService(db, [weather], config);
|
|
30
|
-
|
|
34
|
+
authorize = vi.spyOn(service.operatorClient, "authorize").mockResolvedValue({ ok: true });
|
|
35
|
+
capture = vi.spyOn(service.operatorClient, "capture").mockResolvedValue(undefined);
|
|
36
|
+
release = vi.spyOn(service.operatorClient, "release").mockResolvedValue(undefined);
|
|
31
37
|
fastify = Fastify();
|
|
32
38
|
await fastify.register(routes, { prefix: "/v1", service });
|
|
33
39
|
await fastify.ready();
|
|
@@ -41,7 +47,7 @@ describe("routes", () => {
|
|
|
41
47
|
const flushSettlement = () => new Promise((resolve) => setTimeout(resolve, 10));
|
|
42
48
|
describe("POST /v1/run/resource", () => {
|
|
43
49
|
function voucherFor(functionName = "temperature", overrides = {}) {
|
|
44
|
-
const voucher = callerInvoker.createPaymentVoucher(
|
|
50
|
+
const voucher = callerInvoker.createPaymentVoucher(ORGANIZATION, COST, weather.path, weather.name, functionName, parameters);
|
|
45
51
|
if (Object.keys(overrides).length === 0) {
|
|
46
52
|
return voucher;
|
|
47
53
|
}
|
|
@@ -56,13 +62,30 @@ describe("routes", () => {
|
|
|
56
62
|
payload: { resourcePath: weather.path, resourceName: weather.name, functionName: "temperature", parameters, ...body },
|
|
57
63
|
});
|
|
58
64
|
}
|
|
59
|
-
test("
|
|
65
|
+
test("authorizes a paid function's voucher, runs it, then captures it", async () => {
|
|
60
66
|
const paymentVoucher = voucherFor();
|
|
61
67
|
const response = await runResource({ paymentVoucher });
|
|
62
68
|
expect(response.statusCode).toBe(200);
|
|
63
69
|
expect(response.json()).toEqual({ value: 12, error: null });
|
|
70
|
+
expect(authorize).toHaveBeenCalledExactlyOnceWith(paymentVoucher);
|
|
64
71
|
await flushSettlement();
|
|
65
|
-
expect(
|
|
72
|
+
expect(capture).toHaveBeenCalledExactlyOnceWith({ voucher: paymentVoucher });
|
|
73
|
+
expect(release).not.toHaveBeenCalled();
|
|
74
|
+
});
|
|
75
|
+
test.each([
|
|
76
|
+
[402, "Insufficient balance"],
|
|
77
|
+
[403, "Payer organization is frozen"],
|
|
78
|
+
[503, "Payment operator is unavailable"],
|
|
79
|
+
])("answers the operator's %i refusal without running the function", async (status, error) => {
|
|
80
|
+
authorize.mockResolvedValueOnce({ ok: false, status, error });
|
|
81
|
+
const exec = vi.spyOn(weather.functions[0], "exec");
|
|
82
|
+
const response = await runResource({ paymentVoucher: voucherFor() });
|
|
83
|
+
expect(response.statusCode).toBe(status);
|
|
84
|
+
expect(response.json()).toEqual({ error });
|
|
85
|
+
expect(exec).not.toHaveBeenCalled();
|
|
86
|
+
await flushSettlement();
|
|
87
|
+
expect(capture).not.toHaveBeenCalled();
|
|
88
|
+
expect(release).not.toHaveBeenCalled();
|
|
66
89
|
});
|
|
67
90
|
test("refuses a voucher from a blocked caller, even for an unrestricted function", async () => {
|
|
68
91
|
await service.grantService.block(caller.publicKey);
|
|
@@ -70,7 +93,7 @@ describe("routes", () => {
|
|
|
70
93
|
expect(response.statusCode).toBe(403);
|
|
71
94
|
expect(response.json()).toEqual({ error: "Caller is blocked" });
|
|
72
95
|
await flushSettlement();
|
|
73
|
-
expect(
|
|
96
|
+
expect(capture).not.toHaveBeenCalled();
|
|
74
97
|
});
|
|
75
98
|
test("accepts a voucher that pays more than the cost", async () => {
|
|
76
99
|
const response = await runResource({ paymentVoucher: voucherFor("temperature", { amount: COST + 1 }) });
|
|
@@ -80,11 +103,11 @@ describe("routes", () => {
|
|
|
80
103
|
const response = await runResource({});
|
|
81
104
|
expect(response.statusCode).toBe(402);
|
|
82
105
|
await flushSettlement();
|
|
83
|
-
expect(
|
|
106
|
+
expect(capture).not.toHaveBeenCalled();
|
|
84
107
|
});
|
|
85
108
|
test.each([
|
|
86
109
|
["pays less than the cost", { amount: COST - 1 }],
|
|
87
|
-
["pays a different recipient", {
|
|
110
|
+
["pays a different recipient", { recipientOrganizationId: "org_other" }],
|
|
88
111
|
["has expired", { expiresAt: new Date(Date.now() - 1000).toISOString() }],
|
|
89
112
|
["has an unparseable expiry", { expiresAt: "not-a-date" }],
|
|
90
113
|
])("rejects a voucher that %s", async (_, overrides) => {
|
|
@@ -92,14 +115,14 @@ describe("routes", () => {
|
|
|
92
115
|
expect(response.statusCode).toBe(400);
|
|
93
116
|
expect(response.json()).toEqual({ error: "Invalid payment voucher" });
|
|
94
117
|
await flushSettlement();
|
|
95
|
-
expect(
|
|
118
|
+
expect(capture).not.toHaveBeenCalled();
|
|
96
119
|
});
|
|
97
120
|
test("rejects a voucher whose parameters were tampered with", async () => {
|
|
98
121
|
const response = await runResource({ paymentVoucher: voucherFor(), parameters: [{ name: "city", value: "Bergen" }] });
|
|
99
122
|
expect(response.statusCode).toBe(400);
|
|
100
123
|
});
|
|
101
124
|
test("rejects a voucher issued for a different function", async () => {
|
|
102
|
-
const paymentVoucher = callerInvoker.createPaymentVoucher(
|
|
125
|
+
const paymentVoucher = callerInvoker.createPaymentVoucher(ORGANIZATION, COST, weather.path, weather.name, "humidity", parameters);
|
|
103
126
|
const response = await runResource({ paymentVoucher });
|
|
104
127
|
expect(response.statusCode).toBe(400);
|
|
105
128
|
});
|
|
@@ -122,21 +145,23 @@ describe("routes", () => {
|
|
|
122
145
|
expect(response.statusCode).toBe(200);
|
|
123
146
|
expect(response.json()).toEqual({ value: 80, error: null });
|
|
124
147
|
await flushSettlement();
|
|
125
|
-
expect(
|
|
148
|
+
expect(capture).not.toHaveBeenCalled();
|
|
126
149
|
});
|
|
127
150
|
test("rejects an unknown function without settling the voucher", async () => {
|
|
128
151
|
const response = await runResource({ functionName: "missing", paymentVoucher: voucherFor("missing") });
|
|
129
152
|
expect(response.statusCode).toBe(400);
|
|
130
153
|
expect(response.json()).toEqual({ error: "Function not found" });
|
|
131
154
|
await flushSettlement();
|
|
132
|
-
expect(
|
|
155
|
+
expect(capture).not.toHaveBeenCalled();
|
|
133
156
|
});
|
|
134
|
-
test("
|
|
157
|
+
test("releases the hold when the invoke returns an error", async () => {
|
|
135
158
|
vi.spyOn(service.resourceInvoker, "invoke").mockResolvedValueOnce({ value: null, error: { message: "boom" } });
|
|
136
|
-
const
|
|
159
|
+
const paymentVoucher = voucherFor();
|
|
160
|
+
const response = await runResource({ paymentVoucher });
|
|
137
161
|
expect(response.statusCode).toBe(200);
|
|
138
162
|
await flushSettlement();
|
|
139
|
-
expect(
|
|
163
|
+
expect(capture).not.toHaveBeenCalled();
|
|
164
|
+
expect(release).toHaveBeenCalledExactlyOnceWith(paymentVoucher);
|
|
140
165
|
});
|
|
141
166
|
test("rejects an unknown resource", async () => {
|
|
142
167
|
const response = await runResource({ resourceName: "Missing" });
|
|
@@ -161,7 +186,7 @@ describe("routes", () => {
|
|
|
161
186
|
const response = await run("Bearer secret");
|
|
162
187
|
expect(response.statusCode).toBe(200);
|
|
163
188
|
// RequestScript resource calls evaluate to the invoker's { value, error } result.
|
|
164
|
-
expect(response.json()).toEqual({ returnValue: { value: 12, error: null } });
|
|
189
|
+
expect(response.json()).toEqual({ usage: { tokens: expect.any(Number) }, returnValue: { value: 12, error: null } });
|
|
165
190
|
});
|
|
166
191
|
test("requires an Authorization header", async () => {
|
|
167
192
|
const response = await run();
|
|
@@ -196,6 +221,96 @@ describe("routes", () => {
|
|
|
196
221
|
expect(response.statusCode).toBe(400);
|
|
197
222
|
expect(response.json()).toMatchObject({ error: "Invalid script" });
|
|
198
223
|
});
|
|
224
|
+
test("answers 422 for a script that fails while running", async () => {
|
|
225
|
+
const response = await run("Bearer secret", { source: "request R {\n return 1 / 0\n}" });
|
|
226
|
+
expect(response.statusCode).toBe(422);
|
|
227
|
+
expect(response.json()).toMatchObject({ error: "Script failed", details: expect.stringContaining("Division by zero"), usage: { tokens: 4 } });
|
|
228
|
+
});
|
|
229
|
+
describe("with run tokens", () => {
|
|
230
|
+
const operatorKeys = generateKeyPairSync("ed25519");
|
|
231
|
+
let tokenService;
|
|
232
|
+
let tokenApp;
|
|
233
|
+
let authorizeRun;
|
|
234
|
+
let captureRun;
|
|
235
|
+
let releaseRun;
|
|
236
|
+
beforeEach(async () => {
|
|
237
|
+
const jwk = await exportJWK(operatorKeys.publicKey);
|
|
238
|
+
tokenService = createService(db, [weather], config, { runTokenKeys: createLocalJWKSet({ keys: [{ ...jwk, alg: "EdDSA" }] }) });
|
|
239
|
+
authorizeRun = vi.spyOn(tokenService.operatorClient, "authorizeRun").mockResolvedValue({ ok: true, heldMicroUsd: 50_000, maxTokens: 9 });
|
|
240
|
+
captureRun = vi.spyOn(tokenService.operatorClient, "captureRun").mockResolvedValue(undefined);
|
|
241
|
+
releaseRun = vi.spyOn(tokenService.operatorClient, "releaseRun").mockResolvedValue(undefined);
|
|
242
|
+
vi.spyOn(tokenService.operatorClient, "authorize").mockResolvedValue({ ok: true });
|
|
243
|
+
vi.spyOn(tokenService.operatorClient, "capture").mockResolvedValue(undefined);
|
|
244
|
+
tokenApp = Fastify();
|
|
245
|
+
await tokenApp.register(routes, { prefix: "/v1", service: tokenService });
|
|
246
|
+
await tokenApp.ready();
|
|
247
|
+
});
|
|
248
|
+
afterEach(async () => {
|
|
249
|
+
await tokenApp.close();
|
|
250
|
+
});
|
|
251
|
+
/** A token the operator would issue, for `organizationId`'s client on this node unless `audience` says otherwise. */
|
|
252
|
+
const runToken = (organizationId, options = {}) => new SignJWT({ org: organizationId })
|
|
253
|
+
.setProtectedHeader({ alg: "EdDSA" })
|
|
254
|
+
.setIssuer("stellarium")
|
|
255
|
+
.setSubject("cli_test")
|
|
256
|
+
.setAudience(options.audience ?? config.publicKey)
|
|
257
|
+
.setIssuedAt()
|
|
258
|
+
.setExpirationTime(options.expiresIn ?? "15m")
|
|
259
|
+
.sign(options.key ?? operatorKeys.privateKey);
|
|
260
|
+
const runWith = async (token, source = temperatureSource) => tokenApp.inject({
|
|
261
|
+
method: "POST", url: "/v1/run", headers: { authorization: `Bearer ${token}` }, payload: { source },
|
|
262
|
+
});
|
|
263
|
+
test("bills another organization's run, and charges its call to this node's paid function to the run", async () => {
|
|
264
|
+
const token = await runToken("org_caller");
|
|
265
|
+
const response = await runWith(token);
|
|
266
|
+
expect(response.statusCode).toBe(200);
|
|
267
|
+
expect(response.json()).toEqual({ returnValue: { value: 12, error: null }, usage: { tokens: 17 } });
|
|
268
|
+
const runId = authorizeRun.mock.calls[0][0].runId;
|
|
269
|
+
expect(authorizeRun).toHaveBeenCalledWith({ token, runId: expect.any(String), source: temperatureSource });
|
|
270
|
+
// The local paid function signed a voucher for the run, paying this node's organization.
|
|
271
|
+
const [voucher] = tokenService.operatorClient.authorize.mock.calls[0];
|
|
272
|
+
expect(voucher).toMatchObject({ runId, recipientOrganizationId: ORGANIZATION, amount: COST, publicKey: config.publicKey });
|
|
273
|
+
expect(safeVerifyData(paymentVoucherPayload(voucher), voucher.publicKey, voucher.signature)).toBe(true);
|
|
274
|
+
await vi.waitFor(() => expect(captureRun).toHaveBeenCalledWith({ runId, tokens: 17 }));
|
|
275
|
+
});
|
|
276
|
+
test("runs this node's own organization's tokens free, without the operator", async () => {
|
|
277
|
+
const response = await runWith(await runToken(ORGANIZATION));
|
|
278
|
+
expect(response.json()).toEqual({ returnValue: { value: 12, error: null }, usage: { tokens: 17 } });
|
|
279
|
+
expect(authorizeRun).not.toHaveBeenCalled();
|
|
280
|
+
expect(tokenService.operatorClient.authorize).not.toHaveBeenCalled();
|
|
281
|
+
});
|
|
282
|
+
test("answers the operator's refusal without running the script", async () => {
|
|
283
|
+
authorizeRun.mockResolvedValue({ ok: false, status: 402, error: "Insufficient balance" });
|
|
284
|
+
const response = await runWith(await runToken("org_caller"));
|
|
285
|
+
expect(response.statusCode).toBe(402);
|
|
286
|
+
expect(response.json()).toEqual({ error: "Insufficient balance" });
|
|
287
|
+
expect(tokenService.operatorClient.authorize).not.toHaveBeenCalled();
|
|
288
|
+
});
|
|
289
|
+
test("charges a script that fails for what it executed, and releases a run the node failed", async () => {
|
|
290
|
+
const failing = await runWith(await runToken("org_caller"), "request R {\n return 1 / 0\n}");
|
|
291
|
+
expect(failing.statusCode).toBe(422);
|
|
292
|
+
await vi.waitFor(() => expect(captureRun).toHaveBeenCalledWith({ runId: expect.any(String), tokens: 4 }));
|
|
293
|
+
vi.spyOn(tokenService.resourceResolver, "resolve").mockRejectedValue(new Error("database is gone"));
|
|
294
|
+
const broken = await runWith(await runToken("org_caller"));
|
|
295
|
+
expect(broken.statusCode).toBe(500);
|
|
296
|
+
await vi.waitFor(() => expect(releaseRun).toHaveBeenCalledTimes(1));
|
|
297
|
+
});
|
|
298
|
+
test.each([
|
|
299
|
+
["addressed to another node", () => runToken("org_caller", { audience: generateBase64KeyPair().publicKey })],
|
|
300
|
+
["signed by another key", () => runToken("org_caller", { key: generateKeyPairSync("ed25519").privateKey })],
|
|
301
|
+
["expired", () => runToken("org_caller", { expiresIn: "-1m" })],
|
|
302
|
+
])("refuses a token %s", async (_, token) => {
|
|
303
|
+
const response = await runWith(await token());
|
|
304
|
+
expect(response.statusCode).toBe(401);
|
|
305
|
+
expect(response.json()).toEqual({ error: "Invalid run token" });
|
|
306
|
+
expect(authorizeRun).not.toHaveBeenCalled();
|
|
307
|
+
});
|
|
308
|
+
test("still accepts the API key", async () => {
|
|
309
|
+
const response = await tokenApp.inject({ method: "POST", url: "/v1/run", headers: { authorization: "Bearer secret" }, payload: { source: temperatureSource } });
|
|
310
|
+
expect(response.statusCode).toBe(200);
|
|
311
|
+
expect(authorizeRun).not.toHaveBeenCalled();
|
|
312
|
+
});
|
|
313
|
+
});
|
|
199
314
|
});
|
|
200
315
|
describe("GET /v1/self", () => {
|
|
201
316
|
test("describes this node from its config", async () => {
|
|
@@ -270,7 +385,7 @@ describe("routes", () => {
|
|
|
270
385
|
parameters: [{ name: "city", type: "string" }],
|
|
271
386
|
returnType: "string",
|
|
272
387
|
exec: async () => "sunny",
|
|
273
|
-
paymentManifest: {
|
|
388
|
+
paymentManifest: { costMicroUsd: 500, recipientOrganizationId: ORGANIZATION },
|
|
274
389
|
}],
|
|
275
390
|
});
|
|
276
391
|
function broadcast(resources, privateKey = keys.privateKey, publicKey = keys.publicKey) {
|
|
@@ -294,7 +409,7 @@ describe("routes", () => {
|
|
|
294
409
|
const resources = (await fastify.inject({ method: "GET", url: "/v1/resources" })).json().resources;
|
|
295
410
|
expect(resources.find((r) => r.name === "Forecast")).toMatchObject({
|
|
296
411
|
baseUrl: peer.baseUrl,
|
|
297
|
-
functions: [{ name: "tomorrow", paymentManifest: {
|
|
412
|
+
functions: [{ name: "tomorrow", paymentManifest: { costMicroUsd: 500, recipientOrganizationId: ORGANIZATION } }],
|
|
298
413
|
});
|
|
299
414
|
});
|
|
300
415
|
test("rejects a broadcast from an unknown peer", async () => {
|
|
@@ -366,7 +481,7 @@ describe("restricted functions", () => {
|
|
|
366
481
|
name: "Vault",
|
|
367
482
|
functions: [
|
|
368
483
|
{ 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: {
|
|
484
|
+
{ name: "premium", parameters: [{ name: "city", type: "string" }], returnType: "int32", exec: async () => 9, restricted: true, paymentManifest: { costMicroUsd: COST, recipientOrganizationId: ORGANIZATION } },
|
|
370
485
|
],
|
|
371
486
|
};
|
|
372
487
|
const call = (functionName) => ({ resourcePath: "com.example", resourceName: "Vault", functionName, parameters });
|
|
@@ -379,7 +494,9 @@ describe("restricted functions", () => {
|
|
|
379
494
|
beforeEach(async () => {
|
|
380
495
|
db = await openDatabase(":memory:");
|
|
381
496
|
service = createService(db, [restricted], testConfig());
|
|
382
|
-
vi.spyOn(service.operatorClient, "
|
|
497
|
+
vi.spyOn(service.operatorClient, "authorize").mockResolvedValue({ ok: true });
|
|
498
|
+
vi.spyOn(service.operatorClient, "capture").mockResolvedValue(undefined);
|
|
499
|
+
vi.spyOn(service.operatorClient, "release").mockResolvedValue(undefined);
|
|
383
500
|
callerDb = await openDatabase(":memory:");
|
|
384
501
|
callerGrants = new GrantService(new GrantRepository(callerDb), { baseUrl: "http://caller", ...caller });
|
|
385
502
|
grant = await service.grantService.issue(caller.publicKey, [{ resourcePath: "com.example", resourceName: "Vault" }], { lifetimeMs: 3_600_000 });
|
|
@@ -491,11 +608,11 @@ describe("restricted functions", () => {
|
|
|
491
608
|
expect(response.statusCode).toBe(403);
|
|
492
609
|
});
|
|
493
610
|
test("checks the authorization before the voucher, and needs both for a paid function", async () => {
|
|
494
|
-
const paymentVoucher = callerInvoker.createPaymentVoucher(
|
|
611
|
+
const paymentVoucher = callerInvoker.createPaymentVoucher(ORGANIZATION, COST, "com.example", "Vault", "premium", parameters);
|
|
495
612
|
const unauthorized = await runResource({ ...call("premium"), paymentVoucher });
|
|
496
613
|
expect(unauthorized.statusCode).toBe(403);
|
|
497
614
|
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
498
|
-
expect(service.operatorClient.
|
|
615
|
+
expect(service.operatorClient.authorize).not.toHaveBeenCalled();
|
|
499
616
|
const unpaid = await runResource({ ...call("premium"), authorization: callerGrants.authorize(grant, call("premium")) });
|
|
500
617
|
expect(unpaid.statusCode).toBe(402);
|
|
501
618
|
const paid = await runResource({ ...call("premium"), paymentVoucher, authorization: callerGrants.authorize(grant, call("premium")) });
|
|
@@ -571,15 +688,19 @@ describe("inference functions", () => {
|
|
|
571
688
|
// "Hello world!" is 3 input tokens by the fixture's estimator. 3 at $3/M plus 1,000 at $15/M, rounded up.
|
|
572
689
|
const parameters = [{ name: "prompt", value: "Hello world!" }, { name: "maxTokens", value: 1000 }];
|
|
573
690
|
const MAX_AMOUNT = 15_009;
|
|
574
|
-
const manifest = { ...INFERENCE_RATES,
|
|
691
|
+
const manifest = { ...INFERENCE_RATES, recipientOrganizationId: ORGANIZATION };
|
|
575
692
|
let db;
|
|
576
693
|
let service;
|
|
577
694
|
let fastify;
|
|
578
|
-
let
|
|
695
|
+
let authorize;
|
|
696
|
+
let capture;
|
|
697
|
+
let release;
|
|
579
698
|
beforeEach(async () => {
|
|
580
699
|
db = await openDatabase(":memory:");
|
|
581
700
|
service = createService(db, [weather, model], testConfig());
|
|
582
|
-
|
|
701
|
+
authorize = vi.spyOn(service.operatorClient, "authorize").mockResolvedValue({ ok: true });
|
|
702
|
+
capture = vi.spyOn(service.operatorClient, "capture").mockResolvedValue(undefined);
|
|
703
|
+
release = vi.spyOn(service.operatorClient, "release").mockResolvedValue(undefined);
|
|
583
704
|
fastify = Fastify();
|
|
584
705
|
await fastify.register(routes, { prefix: "/v1", service });
|
|
585
706
|
await fastify.ready();
|
|
@@ -639,7 +760,7 @@ describe("inference functions", () => {
|
|
|
639
760
|
expect(response.statusCode).toBe(200);
|
|
640
761
|
expect(response.json()).toEqual({ value: "echo: Hello world!", error: null, usage: { inputTokens: 3, outputTokens: 1000 } });
|
|
641
762
|
await flushSettlement();
|
|
642
|
-
expect(
|
|
763
|
+
expect(capture).toHaveBeenCalledExactlyOnceWith({ voucher: paymentVoucher, usage: { inputTokens: 3, outputTokens: 1000 } });
|
|
643
764
|
});
|
|
644
765
|
test("refuses a voucher below the estimator's requirement without running inference", async () => {
|
|
645
766
|
const exec = vi.spyOn(model.functions[0], "exec");
|
|
@@ -648,20 +769,20 @@ describe("inference functions", () => {
|
|
|
648
769
|
expect(response.json()).toMatchObject({ requiredMaxAmount: MAX_AMOUNT });
|
|
649
770
|
expect(exec).not.toHaveBeenCalled();
|
|
650
771
|
await flushSettlement();
|
|
651
|
-
expect(
|
|
772
|
+
expect(capture).not.toHaveBeenCalled();
|
|
652
773
|
});
|
|
653
774
|
test.each([
|
|
654
|
-
["a different input rate", {
|
|
655
|
-
["a different output rate", {
|
|
775
|
+
["a different input rate", { inputCostMicroUsdPerMillionTokens: 1 }],
|
|
776
|
+
["a different output rate", { outputCostMicroUsdPerMillionTokens: 1 }],
|
|
656
777
|
["a maxTokens other than the call's", { maxTokens: 999 }],
|
|
657
|
-
["a different recipient", {
|
|
778
|
+
["a different recipient", { recipientOrganizationId: "org_other" }],
|
|
658
779
|
])("rejects a voucher with %s", async (_, overrides) => {
|
|
659
780
|
const response = await call("/v1/run/resource", { paymentVoucher: voucherFor("complete", overrides) });
|
|
660
781
|
expect(response.statusCode).toBe(400);
|
|
661
782
|
expect(response.json()).toEqual({ error: "Invalid payment voucher" });
|
|
662
783
|
});
|
|
663
784
|
test("rejects a fixed voucher for an inference function, and an inference voucher for a fixed function", async () => {
|
|
664
|
-
const fixed = callerInvoker.createPaymentVoucher(
|
|
785
|
+
const fixed = callerInvoker.createPaymentVoucher(ORGANIZATION, MAX_AMOUNT, model.path, model.name, "complete", parameters);
|
|
665
786
|
expect((await call("/v1/run/resource", { paymentVoucher: fixed })).statusCode).toBe(400);
|
|
666
787
|
const weatherParameters = [{ name: "city", value: "Oslo" }];
|
|
667
788
|
const inference = callerInvoker.createInferenceVoucher(manifest, MAX_AMOUNT, 1000, weather.path, weather.name, "temperature", weatherParameters);
|
|
@@ -674,21 +795,25 @@ describe("inference functions", () => {
|
|
|
674
795
|
const response = await call("/v1/run/resource", { paymentVoucher });
|
|
675
796
|
expect(response.json().usage).toEqual({ inputTokens: 3, outputTokens: 1000 });
|
|
676
797
|
await flushSettlement();
|
|
677
|
-
expect(
|
|
798
|
+
expect(capture).toHaveBeenCalledExactlyOnceWith({ voucher: paymentVoucher, usage: { inputTokens: 3, outputTokens: 1000 } });
|
|
678
799
|
});
|
|
679
|
-
test("
|
|
800
|
+
test("releases the hold of a call that charges nothing", async () => {
|
|
680
801
|
vi.spyOn(model.functions[0], "exec").mockResolvedValue({ value: "", usage: { inputTokens: 0, outputTokens: 0 } });
|
|
681
|
-
const
|
|
802
|
+
const paymentVoucher = voucherFor();
|
|
803
|
+
const response = await call("/v1/run/resource", { paymentVoucher });
|
|
682
804
|
expect(response.statusCode).toBe(200);
|
|
683
805
|
await flushSettlement();
|
|
684
|
-
expect(
|
|
806
|
+
expect(capture).not.toHaveBeenCalled();
|
|
807
|
+
expect(release).toHaveBeenCalledExactlyOnceWith(paymentVoucher);
|
|
685
808
|
});
|
|
686
|
-
test("
|
|
809
|
+
test("releases the hold when the function doesn't report usage", async () => {
|
|
687
810
|
vi.spyOn(model.functions[0], "exec").mockResolvedValue("no usage");
|
|
688
|
-
const
|
|
811
|
+
const paymentVoucher = voucherFor();
|
|
812
|
+
const response = await call("/v1/run/resource", { paymentVoucher });
|
|
689
813
|
expect(response.json().error.message).toContain("must return { value, usage");
|
|
690
814
|
await flushSettlement();
|
|
691
|
-
expect(
|
|
815
|
+
expect(capture).not.toHaveBeenCalled();
|
|
816
|
+
expect(release).toHaveBeenCalledExactlyOnceWith(paymentVoucher);
|
|
692
817
|
});
|
|
693
818
|
});
|
|
694
819
|
test("GET /v1/resources announces the kind and per-token pricing", async () => {
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { afterEach, describe, expect, test, vi } from "vitest";
|
|
2
|
-
import {
|
|
2
|
+
import { DEFAULT_COST_LIMIT_MICRO_USD, resolveConfig } from "../server.js";
|
|
3
3
|
import { modelResource, weatherResource } from "./helpers.js";
|
|
4
4
|
describe("resolveConfig", () => {
|
|
5
5
|
const keys = { baseUrl: "http://node.example", publicKey: "public", privateKey: "private" };
|
|
@@ -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("
|
|
19
|
-
expect(resolveConfig({})).toMatchObject({ baseUrl: "http://env.example", publicKey: "env-public", privateKey: "env-private",
|
|
18
|
+
vi.stubEnv("COST_LIMIT_MICRO_USD", "500");
|
|
19
|
+
expect(resolveConfig({})).toMatchObject({ baseUrl: "http://env.example", publicKey: "env-public", privateKey: "env-private", costLimitMicroUsd: 500 });
|
|
20
20
|
});
|
|
21
21
|
test("defaults the cost limit", () => {
|
|
22
|
-
vi.stubEnv("
|
|
23
|
-
expect(resolveConfig(keys).
|
|
22
|
+
vi.stubEnv("COST_LIMIT_MICRO_USD", undefined);
|
|
23
|
+
expect(resolveConfig(keys).costLimitMicroUsd).toBe(DEFAULT_COST_LIMIT_MICRO_USD);
|
|
24
24
|
});
|
|
25
|
-
test.each([-1, 1.5, Number.NaN])("rejects a cost limit of %s", (
|
|
26
|
-
expect(() => resolveConfig({ ...keys,
|
|
25
|
+
test.each([-1, 1.5, Number.NaN])("rejects a cost limit of %s", (costLimitMicroUsd) => {
|
|
26
|
+
expect(() => resolveConfig({ ...keys, costLimitMicroUsd })).toThrow("costLimitMicroUsd");
|
|
27
27
|
});
|
|
28
28
|
test("requires the signing keys and base URL", () => {
|
|
29
29
|
vi.stubEnv("SIGNING_PRIVATE_KEY", undefined);
|
|
@@ -35,12 +35,12 @@ describe("resolveConfig", () => {
|
|
|
35
35
|
expect(() => resolveConfig(keys, [weatherResource()])).toThrow("OPERATOR_URL is required");
|
|
36
36
|
});
|
|
37
37
|
test("defaults the inference cost limit to the cost limit, and reads its own option or environment variable", () => {
|
|
38
|
-
vi.stubEnv("
|
|
39
|
-
expect(resolveConfig({ ...keys,
|
|
40
|
-
vi.stubEnv("
|
|
41
|
-
expect(resolveConfig({ ...keys,
|
|
42
|
-
expect(resolveConfig({ ...keys,
|
|
43
|
-
expect(() => resolveConfig({ ...keys,
|
|
38
|
+
vi.stubEnv("INFERENCE_COST_LIMIT_MICRO_USD", undefined);
|
|
39
|
+
expect(resolveConfig({ ...keys, costLimitMicroUsd: 700 }).inferenceCostLimitMicroUsd).toBe(700);
|
|
40
|
+
vi.stubEnv("INFERENCE_COST_LIMIT_MICRO_USD", "900");
|
|
41
|
+
expect(resolveConfig({ ...keys, costLimitMicroUsd: 700 }).inferenceCostLimitMicroUsd).toBe(900);
|
|
42
|
+
expect(resolveConfig({ ...keys, inferenceCostLimitMicroUsd: 1_000 }).inferenceCostLimitMicroUsd).toBe(1_000);
|
|
43
|
+
expect(() => resolveConfig({ ...keys, inferenceCostLimitMicroUsd: -1 })).toThrow("inferenceCostLimitMicroUsd");
|
|
44
44
|
});
|
|
45
45
|
test("accepts well-declared inference functions", () => {
|
|
46
46
|
expect(() => resolveConfig({ ...keys, operatorUrl: "http://operator.example" }, [modelResource()])).not.toThrow();
|
|
@@ -49,8 +49,8 @@ describe("resolveConfig", () => {
|
|
|
49
49
|
["without a maxTokens parameter", { parameters: [{ name: "prompt", type: "string" }] }, "maxTokens: int32"],
|
|
50
50
|
["whose maxTokens isn't an int32", { parameters: [{ name: "maxTokens", type: "int64" }] }, "maxTokens: int32"],
|
|
51
51
|
["that is paid but has no estimator", { estimateInputTokens: undefined }, "estimateInputTokens"],
|
|
52
|
-
["priced with a flat cost", { paymentManifest: {
|
|
53
|
-
["with a negative rate", { paymentManifest: {
|
|
52
|
+
["priced with a flat cost", { paymentManifest: { costMicroUsd: 5, recipientOrganizationId: "r" } }, "per million tokens"],
|
|
53
|
+
["with a negative rate", { paymentManifest: { inputCostMicroUsdPerMillionTokens: -1, outputCostMicroUsdPerMillionTokens: 0, recipientOrganizationId: "r" } }, "per million tokens"],
|
|
54
54
|
])("rejects an inference function %s", (_, overrides, message) => {
|
|
55
55
|
const model = modelResource();
|
|
56
56
|
model.functions = [{ ...model.functions[0], ...overrides }];
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "stellarium-ts",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.18.0",
|
|
4
4
|
"description": "A Stellarium Node in TypeScript.",
|
|
5
5
|
"homepage": "https://github.com/qualletio/stellarium-ts#readme",
|
|
6
6
|
"bugs": {
|
|
@@ -45,7 +45,8 @@
|
|
|
45
45
|
"@fastify/rate-limit": "^11.2.1",
|
|
46
46
|
"dotenv": "^18.0.5",
|
|
47
47
|
"fastify": "^5.12.5",
|
|
48
|
-
"
|
|
48
|
+
"jose": "6.1.0",
|
|
49
|
+
"requestscript": "1.2.0",
|
|
49
50
|
"sqlite": "^5.1.1",
|
|
50
51
|
"sqlite3": "^6.0.1"
|
|
51
52
|
},
|