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