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