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
|
@@ -1,39 +1,94 @@
|
|
|
1
1
|
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
|
2
|
-
import {
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
2
|
+
import { AUTHORIZE_MAX_RETRIES, OPERATOR_MAX_RETRIES, OperatorClient } from "../../client/operatorClient.js";
|
|
3
|
+
import { OPERATOR_AUTH_SCHEME, operatorRequestPayload } from "../../client/operatorAuth.js";
|
|
4
|
+
import { verifyData } from "../../crypto/utils.js";
|
|
5
|
+
import { generateBase64KeyPair } from "../helpers.js";
|
|
6
|
+
const keys = generateBase64KeyPair();
|
|
7
|
+
const client = () => new OperatorClient("https://operator.example/v1", keys.publicKey, keys.privateKey);
|
|
8
|
+
const payment = {
|
|
9
|
+
voucher: {
|
|
10
|
+
requestId: "request-1",
|
|
11
|
+
invocationId: "invocation-1",
|
|
12
|
+
amount: 10000,
|
|
13
|
+
recipientOrganizationId: "org_provider",
|
|
14
|
+
signature: "signature",
|
|
15
|
+
expiresAt: "2030-01-01T00:00:00.000Z",
|
|
16
|
+
invocationHash: "hash",
|
|
17
|
+
publicKey: "public-key",
|
|
18
|
+
},
|
|
19
|
+
};
|
|
20
|
+
const ok = (body = { success: true }) => new Response(JSON.stringify(body), { status: 200 });
|
|
21
|
+
const status = (code, headers) => new Response(null, { status: code, headers });
|
|
22
|
+
const refusal = (code, error) => new Response(JSON.stringify({ error }), { status: code });
|
|
23
|
+
const networkError = (code) => new TypeError("fetch failed", { cause: Object.assign(new Error(code), { code }) });
|
|
24
|
+
let fetchMock;
|
|
25
|
+
beforeEach(() => {
|
|
26
|
+
vi.useFakeTimers();
|
|
27
|
+
vi.spyOn(console, "error").mockImplementation(() => { });
|
|
28
|
+
fetchMock = vi.fn();
|
|
29
|
+
vi.stubGlobal("fetch", fetchMock);
|
|
30
|
+
});
|
|
31
|
+
afterEach(() => {
|
|
32
|
+
vi.useRealTimers();
|
|
33
|
+
vi.unstubAllGlobals();
|
|
34
|
+
vi.restoreAllMocks();
|
|
35
|
+
});
|
|
36
|
+
describe("request signing", () => {
|
|
37
|
+
test("signs the method, path, body hash and timestamp with the node key", async () => {
|
|
38
|
+
fetchMock.mockResolvedValue(ok());
|
|
39
|
+
await client().capture(payment);
|
|
40
|
+
const [url, init] = fetchMock.mock.calls[0];
|
|
41
|
+
expect(url).toBe("https://operator.example/v1/payments/capture");
|
|
42
|
+
const [scheme, encoded] = init.headers.Authorization.split(" ");
|
|
43
|
+
expect(scheme).toBe(OPERATOR_AUTH_SCHEME);
|
|
44
|
+
const credentials = JSON.parse(Buffer.from(encoded, "base64url").toString());
|
|
45
|
+
expect(credentials.publicKey).toBe(keys.publicKey);
|
|
46
|
+
const payload = operatorRequestPayload("POST", "/v1/payments/capture", JSON.parse(init.body), credentials.timestamp);
|
|
47
|
+
expect(verifyData(payload, keys.publicKey, credentials.signature)).toBe(true);
|
|
48
|
+
});
|
|
49
|
+
});
|
|
50
|
+
describe("session", () => {
|
|
51
|
+
test("returns the node's organization, sending the enrollment token when given", async () => {
|
|
52
|
+
fetchMock.mockResolvedValue(ok({ organizationId: "org_provider" }));
|
|
53
|
+
await expect(client().session("enroll-1")).resolves.toEqual({ organizationId: "org_provider" });
|
|
54
|
+
expect(JSON.parse(fetchMock.mock.calls[0][1].body)).toEqual({ enrollmentToken: "enroll-1" });
|
|
25
55
|
});
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
56
|
+
test("throws the operator's refusal", async () => {
|
|
57
|
+
fetchMock.mockResolvedValue(refusal(403, "Node is not enrolled in an organization"));
|
|
58
|
+
await expect(client().session()).rejects.toThrow("Node is not enrolled in an organization");
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
describe("authorize", () => {
|
|
62
|
+
test("reports a hold as ok", async () => {
|
|
63
|
+
fetchMock.mockResolvedValue(ok());
|
|
64
|
+
await expect(client().authorize(payment.voucher)).resolves.toEqual({ ok: true });
|
|
65
|
+
});
|
|
66
|
+
test.each([[402, "Insufficient balance"], [403, "Payer organization is frozen"], [422, "Recipient organization can't receive payouts"]])("passes a %i refusal through without retrying", async (code, error) => {
|
|
67
|
+
fetchMock.mockResolvedValue(refusal(code, error));
|
|
68
|
+
await expect(client().authorize(payment.voucher)).resolves.toEqual({ ok: false, status: code, error });
|
|
69
|
+
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
30
70
|
});
|
|
71
|
+
test("reports a persistent server error as 503 after its few retries", async () => {
|
|
72
|
+
fetchMock.mockResolvedValue(status(500));
|
|
73
|
+
const pending = client().authorize(payment.voucher);
|
|
74
|
+
await vi.runAllTimersAsync();
|
|
75
|
+
await expect(pending).resolves.toMatchObject({ ok: false, status: 503 });
|
|
76
|
+
expect(fetchMock).toHaveBeenCalledTimes(1 + AUTHORIZE_MAX_RETRIES);
|
|
77
|
+
});
|
|
78
|
+
test("reports an unreachable operator as 503", async () => {
|
|
79
|
+
fetchMock.mockRejectedValue(networkError("ECONNREFUSED"));
|
|
80
|
+
const pending = client().authorize(payment.voucher);
|
|
81
|
+
await vi.runAllTimersAsync();
|
|
82
|
+
await expect(pending).resolves.toEqual({ ok: false, status: 503, error: "Payment operator is unavailable" });
|
|
83
|
+
});
|
|
84
|
+
});
|
|
85
|
+
describe("capture and release retries", () => {
|
|
31
86
|
test("retries 429 responses with exponential backoff, then succeeds", async () => {
|
|
32
87
|
fetchMock
|
|
33
88
|
.mockResolvedValueOnce(status(429))
|
|
34
89
|
.mockResolvedValueOnce(status(429))
|
|
35
90
|
.mockResolvedValueOnce(ok());
|
|
36
|
-
const pending =
|
|
91
|
+
const pending = client().capture(payment);
|
|
37
92
|
await vi.advanceTimersByTimeAsync(0);
|
|
38
93
|
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
39
94
|
await vi.advanceTimersByTimeAsync(999);
|
|
@@ -49,7 +104,7 @@ describe("settle payment retries", () => {
|
|
|
49
104
|
});
|
|
50
105
|
test("waits for Retry-After when it is longer than the backoff", async () => {
|
|
51
106
|
fetchMock.mockResolvedValueOnce(status(429, { "retry-after": "30" })).mockResolvedValueOnce(ok());
|
|
52
|
-
const pending =
|
|
107
|
+
const pending = client().release(payment.voucher);
|
|
53
108
|
await vi.advanceTimersByTimeAsync(29_999);
|
|
54
109
|
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
55
110
|
await vi.advanceTimersByTimeAsync(1);
|
|
@@ -58,7 +113,7 @@ describe("settle payment retries", () => {
|
|
|
58
113
|
});
|
|
59
114
|
test.each([408, 500, 502, 503, 504])("retries %i responses", async (code) => {
|
|
60
115
|
fetchMock.mockResolvedValueOnce(status(code)).mockResolvedValueOnce(ok());
|
|
61
|
-
const pending =
|
|
116
|
+
const pending = client().capture(payment);
|
|
62
117
|
await vi.runAllTimersAsync();
|
|
63
118
|
await pending;
|
|
64
119
|
expect(fetchMock).toHaveBeenCalledTimes(2);
|
|
@@ -66,31 +121,34 @@ describe("settle payment retries", () => {
|
|
|
66
121
|
});
|
|
67
122
|
test.each([400, 401, 404, 409, 422])("does not retry %i responses", async (code) => {
|
|
68
123
|
fetchMock.mockResolvedValue(status(code));
|
|
69
|
-
await
|
|
124
|
+
await client().capture(payment);
|
|
70
125
|
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
71
126
|
expect(console.error).toHaveBeenCalledOnce();
|
|
72
127
|
});
|
|
73
128
|
test.each(["ECONNREFUSED", "ECONNRESET"])("retries %s network errors", async (code) => {
|
|
74
129
|
fetchMock.mockRejectedValueOnce(networkError(code)).mockResolvedValueOnce(ok());
|
|
75
|
-
const pending =
|
|
130
|
+
const pending = client().capture(payment);
|
|
76
131
|
await vi.runAllTimersAsync();
|
|
77
132
|
await pending;
|
|
78
133
|
expect(fetchMock).toHaveBeenCalledTimes(2);
|
|
79
134
|
});
|
|
80
135
|
test("rethrows the last network error once retries are exhausted", async () => {
|
|
81
136
|
fetchMock.mockRejectedValue(networkError("ECONNREFUSED"));
|
|
82
|
-
const pending =
|
|
137
|
+
const pending = client().capture(payment);
|
|
83
138
|
const assertion = expect(pending).rejects.toThrow("fetch failed");
|
|
84
139
|
await vi.runAllTimersAsync();
|
|
85
140
|
await assertion;
|
|
86
|
-
expect(fetchMock).toHaveBeenCalledTimes(1 +
|
|
141
|
+
expect(fetchMock).toHaveBeenCalledTimes(1 + OPERATOR_MAX_RETRIES);
|
|
87
142
|
});
|
|
88
143
|
test("gives up after the maximum number of retries", async () => {
|
|
89
144
|
fetchMock.mockResolvedValue(status(429));
|
|
90
|
-
const pending =
|
|
145
|
+
const pending = client().capture(payment);
|
|
91
146
|
await vi.runAllTimersAsync();
|
|
92
147
|
await pending;
|
|
93
|
-
expect(fetchMock).toHaveBeenCalledTimes(1 +
|
|
148
|
+
expect(fetchMock).toHaveBeenCalledTimes(1 + OPERATOR_MAX_RETRIES);
|
|
94
149
|
expect(console.error).toHaveBeenCalledOnce();
|
|
95
150
|
});
|
|
151
|
+
test("throws when no operator URL is configured", async () => {
|
|
152
|
+
await expect(new OperatorClient(undefined, keys.publicKey, keys.privateKey).capture(payment)).rejects.toThrow("No operator URL is configured");
|
|
153
|
+
});
|
|
96
154
|
});
|
package/dist/tests/db/db.test.js
CHANGED
|
@@ -17,7 +17,7 @@ describe("openDatabase", () => {
|
|
|
17
17
|
const filename = path.join(dir, "node.db");
|
|
18
18
|
await (await openDatabase(filename)).close();
|
|
19
19
|
const db = await openDatabase(filename);
|
|
20
|
-
expect(await db.get("PRAGMA user_version")).toEqual({ user_version:
|
|
20
|
+
expect(await db.get("PRAGMA user_version")).toEqual({ user_version: 6 });
|
|
21
21
|
await db.close();
|
|
22
22
|
});
|
|
23
23
|
test("rebuilds resource tables from the old schema and keeps peers", async () => {
|
|
@@ -61,4 +61,21 @@ describe("openDatabase", () => {
|
|
|
61
61
|
expect(await reopened.all("SELECT id FROM held_grant")).toEqual([{ id: "g" }]);
|
|
62
62
|
await reopened.close();
|
|
63
63
|
});
|
|
64
|
+
test("rebuilds a version 4 manifest table into the micro-USD, organization-recipient schema", async () => {
|
|
65
|
+
const filename = path.join(dir, "v4.db");
|
|
66
|
+
const old = await open({ filename, driver: sqlite3.Database });
|
|
67
|
+
await old.exec(`
|
|
68
|
+
CREATE TABLE resource_function_payment_manifest (id INTEGER PRIMARY KEY AUTOINCREMENT, resource_function_id INTEGER NOT NULL,
|
|
69
|
+
cost_micro_usdc INTEGER NOT NULL, recipient_address TEXT NOT NULL, created_at TEXT NOT NULL, updated_at TEXT NOT NULL,
|
|
70
|
+
UNIQUE (resource_function_id));
|
|
71
|
+
PRAGMA user_version = 4;
|
|
72
|
+
`);
|
|
73
|
+
await old.close();
|
|
74
|
+
const db = await openDatabase(filename);
|
|
75
|
+
const functionColumns = await db.all("PRAGMA table_info(resource_function)");
|
|
76
|
+
expect(functionColumns.map(column => column.name)).toContain("kind");
|
|
77
|
+
await db.run(`INSERT INTO resource_function_payment_manifest (resource_function_id, input_cost_micro_usd_per_million_tokens,
|
|
78
|
+
output_cost_micro_usd_per_million_tokens, recipient_organization_id, created_at, updated_at) VALUES (1, 3, 15, 'r', 'now', 'now')`);
|
|
79
|
+
await db.close();
|
|
80
|
+
});
|
|
64
81
|
});
|
package/dist/tests/helpers.d.ts
CHANGED
|
@@ -4,10 +4,20 @@ export declare function generateBase64KeyPair(): {
|
|
|
4
4
|
publicKey: string;
|
|
5
5
|
privateKey: string;
|
|
6
6
|
};
|
|
7
|
-
|
|
7
|
+
/** The operator organization the fixture provider is enrolled in. */
|
|
8
|
+
export declare const ORGANIZATION = "org_provider";
|
|
8
9
|
export declare const COST = 10000;
|
|
9
10
|
export declare function testConfig(overrides?: Partial<NodeConfig>): NodeConfig;
|
|
10
11
|
/** The fixture provider's resource: paid `temperature` and free `humidity`. */
|
|
11
12
|
export declare function weatherResource(baseUrl?: string): StellariumResource;
|
|
13
|
+
export declare const INFERENCE_RATES: {
|
|
14
|
+
inputCostMicroUsdPerMillionTokens: number;
|
|
15
|
+
outputCostMicroUsdPerMillionTokens: number;
|
|
16
|
+
};
|
|
17
|
+
/**
|
|
18
|
+
* A fixture provider's model: paid inference `complete`, which counts one token per four prompt characters
|
|
19
|
+
* and answers with `maxTokens` tokens, a restricted paid copy `completePrivate`, and free inference `draft`.
|
|
20
|
+
*/
|
|
21
|
+
export declare function modelResource(baseUrl?: string): StellariumResource;
|
|
12
22
|
export declare const temperatureSource = "request GetTemperature {\n const weather: com.example.Weather\n\n return weather.temperature(city: \"Oslo\")\n}";
|
|
13
23
|
//# sourceMappingURL=helpers.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"helpers.d.ts","sourceRoot":"","sources":["../../src/tests/helpers.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,2BAA2B,CAAC;AACpE,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,eAAe,CAAC;AAEhD,wBAAgB,qBAAqB;IAM7B,SAAS;IACT,UAAU;EAEjB;AAED,eAAO,MAAM,
|
|
1
|
+
{"version":3,"file":"helpers.d.ts","sourceRoot":"","sources":["../../src/tests/helpers.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,2BAA2B,CAAC;AACpE,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,eAAe,CAAC;AAEhD,wBAAgB,qBAAqB;IAM7B,SAAS;IACT,UAAU;EAEjB;AAED,qEAAqE;AACrE,eAAO,MAAM,YAAY,iBAAiB,CAAC;AAC3C,eAAO,MAAM,IAAI,QAAQ,CAAC;AAE1B,wBAAgB,UAAU,CAAC,SAAS,GAAE,OAAO,CAAC,UAAU,CAAM,GAAG,UAAU,CAW1E;AAED,+EAA+E;AAC/E,wBAAgB,eAAe,CAAC,OAAO,SAA0B,GAAG,kBAAkB,CAuBrF;AAED,eAAO,MAAM,eAAe;IAAK,iCAAiC;IAAa,kCAAkC;CAAc,CAAC;AAEhI;;;GAGG;AACH,wBAAgB,aAAa,CAAC,OAAO,SAA0B,GAAG,kBAAkB,CA0BnF;AAED,eAAO,MAAM,iBAAiB,sHAAoH,CAAC"}
|
package/dist/tests/helpers.js
CHANGED
|
@@ -9,7 +9,8 @@ export function generateBase64KeyPair() {
|
|
|
9
9
|
privateKey: privateKey.toString("base64url"),
|
|
10
10
|
};
|
|
11
11
|
}
|
|
12
|
-
|
|
12
|
+
/** The operator organization the fixture provider is enrolled in. */
|
|
13
|
+
export const ORGANIZATION = "org_provider";
|
|
13
14
|
export const COST = 10000;
|
|
14
15
|
export function testConfig(overrides = {}) {
|
|
15
16
|
return {
|
|
@@ -17,7 +18,9 @@ export function testConfig(overrides = {}) {
|
|
|
17
18
|
...generateBase64KeyPair(),
|
|
18
19
|
apiKey: "secret",
|
|
19
20
|
operatorUrl: "http://operator.invalid/v1",
|
|
20
|
-
|
|
21
|
+
organizationId: ORGANIZATION,
|
|
22
|
+
costLimitMicroUsd: 100_000,
|
|
23
|
+
inferenceCostLimitMicroUsd: 100_000,
|
|
21
24
|
...overrides,
|
|
22
25
|
};
|
|
23
26
|
}
|
|
@@ -35,7 +38,7 @@ export function weatherResource(baseUrl = "http://127.0.0.1:3002") {
|
|
|
35
38
|
parameters: [{ name: "city", type: "string" }],
|
|
36
39
|
returnType: "int32",
|
|
37
40
|
exec: async (args) => (args.find((arg) => arg.name === "city")?.value === "Oslo" ? 12 : 20),
|
|
38
|
-
paymentManifest: {
|
|
41
|
+
paymentManifest: { costMicroUsd: COST },
|
|
39
42
|
},
|
|
40
43
|
{
|
|
41
44
|
name: "humidity",
|
|
@@ -46,4 +49,36 @@ export function weatherResource(baseUrl = "http://127.0.0.1:3002") {
|
|
|
46
49
|
],
|
|
47
50
|
};
|
|
48
51
|
}
|
|
52
|
+
export const INFERENCE_RATES = { inputCostMicroUsdPerMillionTokens: 3_000_000, outputCostMicroUsdPerMillionTokens: 15_000_000 };
|
|
53
|
+
/**
|
|
54
|
+
* A fixture provider's model: paid inference `complete`, which counts one token per four prompt characters
|
|
55
|
+
* and answers with `maxTokens` tokens, a restricted paid copy `completePrivate`, and free inference `draft`.
|
|
56
|
+
*/
|
|
57
|
+
export function modelResource(baseUrl = "http://127.0.0.1:3002") {
|
|
58
|
+
const prompt = (args) => String(args.find((arg) => arg.name === "prompt")?.value ?? "");
|
|
59
|
+
const complete = {
|
|
60
|
+
name: "complete",
|
|
61
|
+
kind: "inference",
|
|
62
|
+
parameters: [{ name: "prompt", type: "string" }, { name: "maxTokens", type: "int32" }],
|
|
63
|
+
returnType: "string",
|
|
64
|
+
estimateInputTokens: async (args) => Math.ceil(prompt(args).length / 4),
|
|
65
|
+
exec: async (args) => ({
|
|
66
|
+
value: `echo: ${prompt(args)}`,
|
|
67
|
+
usage: { inputTokens: Math.ceil(prompt(args).length / 4), outputTokens: Number(args.find((arg) => arg.name === "maxTokens")?.value) },
|
|
68
|
+
}),
|
|
69
|
+
paymentManifest: { ...INFERENCE_RATES },
|
|
70
|
+
};
|
|
71
|
+
return {
|
|
72
|
+
id: 2,
|
|
73
|
+
baseUrl,
|
|
74
|
+
metadata: {},
|
|
75
|
+
path: "com.example",
|
|
76
|
+
name: "Model",
|
|
77
|
+
functions: [
|
|
78
|
+
complete,
|
|
79
|
+
{ ...complete, name: "completePrivate", restricted: true },
|
|
80
|
+
{ ...complete, name: "draft", paymentManifest: undefined, estimateInputTokens: undefined },
|
|
81
|
+
],
|
|
82
|
+
};
|
|
83
|
+
}
|
|
49
84
|
export const temperatureSource = 'request GetTemperature {\n const weather: com.example.Weather\n\n return weather.temperature(city: "Oslo")\n}';
|
|
@@ -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,
|
|
10
|
+
import { COST, generateBase64KeyPair, INFERENCE_RATES, modelResource, ORGANIZATION, 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();
|
|
@@ -34,12 +35,18 @@ function freePort() {
|
|
|
34
35
|
}
|
|
35
36
|
/**
|
|
36
37
|
* A provider node hosting the paid weather resource, a caller node that bootstraps from it,
|
|
37
|
-
* and a fake operator that records
|
|
38
|
+
* and a fake operator that enrolls the provider and records holds and captures. 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.
|
|
40
41
|
describe("two-node network", { timeout: 15_000 }, () => {
|
|
41
42
|
const servers = [];
|
|
42
43
|
const settlements = [];
|
|
44
|
+
const submissions = [];
|
|
45
|
+
const authorizations = [];
|
|
46
|
+
const releases = [];
|
|
47
|
+
const sessions = [];
|
|
48
|
+
/** What the fake operator answers to the next authorizations; undefined places the hold. */
|
|
49
|
+
let refuseAuthorization;
|
|
43
50
|
const callerKeys = generateBase64KeyPair();
|
|
44
51
|
let dir;
|
|
45
52
|
let operatorUrl;
|
|
@@ -73,11 +80,35 @@ describe("two-node network", { timeout: 15_000 }, () => {
|
|
|
73
80
|
beforeEach(async () => {
|
|
74
81
|
dir = await mkdtemp(path.join(tmpdir(), "stellarium-network-"));
|
|
75
82
|
settlements.length = 0;
|
|
83
|
+
submissions.length = 0;
|
|
84
|
+
authorizations.length = 0;
|
|
85
|
+
releases.length = 0;
|
|
86
|
+
sessions.length = 0;
|
|
87
|
+
refuseAuthorization = undefined;
|
|
76
88
|
const operator = Fastify();
|
|
77
89
|
servers.push(operator);
|
|
78
|
-
operator.post("/v1/
|
|
90
|
+
operator.post("/v1/nodes/session", async (request, reply) => {
|
|
91
|
+
if (!String(request.headers.authorization).startsWith("Stellarium ")) {
|
|
92
|
+
return reply.status(401).send({ error: "Unsigned request" });
|
|
93
|
+
}
|
|
94
|
+
sessions.push(request.body);
|
|
95
|
+
return { organizationId: ORGANIZATION };
|
|
96
|
+
});
|
|
97
|
+
operator.post("/v1/payments/authorize", async (request, reply) => {
|
|
98
|
+
authorizations.push(request.body);
|
|
99
|
+
if (refuseAuthorization) {
|
|
100
|
+
return reply.status(refuseAuthorization.status).send({ error: refuseAuthorization.error });
|
|
101
|
+
}
|
|
102
|
+
return { status: "held" };
|
|
103
|
+
});
|
|
104
|
+
operator.post("/v1/payments/capture", async (request) => {
|
|
79
105
|
settlements.push(request.body.voucher);
|
|
80
|
-
|
|
106
|
+
submissions.push(request.body);
|
|
107
|
+
return { status: "captured" };
|
|
108
|
+
});
|
|
109
|
+
operator.post("/v1/payments/release", async (request) => {
|
|
110
|
+
releases.push(request.body);
|
|
111
|
+
return { status: "released" };
|
|
81
112
|
});
|
|
82
113
|
operatorUrl = `${await operator.listen({ port: 0, host: "127.0.0.1" })}/v1`;
|
|
83
114
|
const providerPort = await freePort();
|
|
@@ -85,6 +116,7 @@ describe("two-node network", { timeout: 15_000 }, () => {
|
|
|
85
116
|
provider = new StellariumNode();
|
|
86
117
|
await provider.register(weatherResource(providerUrl));
|
|
87
118
|
await provider.register(vaultResource(providerUrl));
|
|
119
|
+
await provider.register(modelResource(providerUrl));
|
|
88
120
|
await startNode(provider, {
|
|
89
121
|
port: providerPort,
|
|
90
122
|
...generateBase64KeyPair(),
|
|
@@ -107,12 +139,13 @@ describe("two-node network", { timeout: 15_000 }, () => {
|
|
|
107
139
|
expect(callerResources.resources).toContainEqual(expect.objectContaining({
|
|
108
140
|
name: "Weather",
|
|
109
141
|
baseUrl: providerUrl,
|
|
110
|
-
functions: expect.arrayContaining([expect.objectContaining({ name: "temperature", paymentManifest: {
|
|
142
|
+
functions: expect.arrayContaining([expect.objectContaining({ name: "temperature", paymentManifest: { costMicroUsd: COST, recipientOrganizationId: ORGANIZATION } })]),
|
|
111
143
|
}));
|
|
112
144
|
expect(await run(caller.url)).toEqual({ returnValue: { value: 12, error: null } });
|
|
113
145
|
await vi.waitFor(() => expect(settlements).toHaveLength(1));
|
|
114
146
|
const [voucher] = settlements;
|
|
115
|
-
expect(
|
|
147
|
+
expect(authorizations).toEqual([{ voucher }]);
|
|
148
|
+
expect(voucher).toMatchObject({ amount: COST, recipientOrganizationId: ORGANIZATION, publicKey: callerKeys.publicKey });
|
|
116
149
|
expect(verifyData(paymentVoucherPayload(voucher), voucher.publicKey, voucher.signature)).toBe(true);
|
|
117
150
|
});
|
|
118
151
|
test("the caller restarts on the same database without failing", async () => {
|
|
@@ -180,10 +213,48 @@ describe("two-node network", { timeout: 15_000 }, () => {
|
|
|
180
213
|
});
|
|
181
214
|
});
|
|
182
215
|
test("the caller refuses a call that costs more than its limit", async () => {
|
|
183
|
-
const caller = await startCaller({
|
|
216
|
+
const caller = await startCaller({ costLimitMicroUsd: COST - 1 });
|
|
184
217
|
const result = await run(caller.url);
|
|
185
218
|
expect(result.returnValue.value).toBeNull();
|
|
186
219
|
expect(result.returnValue.error.message).toContain("exceeds the cost limit");
|
|
187
220
|
expect(settlements).toEqual([]);
|
|
221
|
+
expect(authorizations).toEqual([]);
|
|
222
|
+
});
|
|
223
|
+
test("a caller with an enrollment token enrolls at startup; one without doesn't contact the operator", async () => {
|
|
224
|
+
sessions.length = 0;
|
|
225
|
+
await startCaller({ operatorUrl, enrollmentToken: "enr_caller" });
|
|
226
|
+
expect(sessions).toEqual([{ enrollmentToken: "enr_caller" }]);
|
|
227
|
+
sessions.length = 0;
|
|
228
|
+
await startCaller({ operatorUrl, databasePath: path.join(dir, "caller-2.db") });
|
|
229
|
+
expect(sessions).toEqual([]);
|
|
230
|
+
});
|
|
231
|
+
test("the provider refuses to run a call the operator won't hold, and the caller sees why", async () => {
|
|
232
|
+
const caller = await startCaller();
|
|
233
|
+
refuseAuthorization = { status: 402, error: "Insufficient balance" };
|
|
234
|
+
const result = await run(caller.url);
|
|
235
|
+
expect(result.returnValue.value).toBeNull();
|
|
236
|
+
expect(result.returnValue.error.message).toContain("Insufficient balance");
|
|
237
|
+
expect(authorizations).toHaveLength(1);
|
|
238
|
+
expect(settlements).toEqual([]);
|
|
239
|
+
expect(releases).toEqual([]);
|
|
240
|
+
});
|
|
241
|
+
describe("inference functions", () => {
|
|
242
|
+
test("the caller prices a call with the provider's estimate, pays for it, and the provider settles its usage", async () => {
|
|
243
|
+
const caller = await startCaller();
|
|
244
|
+
const result = await run(caller.url, completeSource);
|
|
245
|
+
expect(result).toEqual({ returnValue: { value: "echo: Hello world!", error: null } });
|
|
246
|
+
await vi.waitFor(() => expect(submissions).toHaveLength(1));
|
|
247
|
+
// 3 input tokens at $3/M plus 10 output tokens at $15/M, rounded up.
|
|
248
|
+
expect(submissions[0]).toMatchObject({
|
|
249
|
+
voucher: { kind: "inference", maxAmount: 159, maxTokens: 10, ...INFERENCE_RATES, recipientOrganizationId: ORGANIZATION, publicKey: callerKeys.publicKey },
|
|
250
|
+
usage: { inputTokens: 3, outputTokens: 10 },
|
|
251
|
+
});
|
|
252
|
+
});
|
|
253
|
+
test("the caller refuses a call whose maxAmount is over its inference cost limit", async () => {
|
|
254
|
+
const caller = await startCaller({ inferenceCostLimitMicroUsd: 158 });
|
|
255
|
+
const result = await run(caller.url, completeSource);
|
|
256
|
+
expect(result.returnValue.error.message).toContain("exceeds the inference cost limit");
|
|
257
|
+
expect(submissions).toEqual([]);
|
|
258
|
+
});
|
|
188
259
|
});
|
|
189
260
|
});
|
|
@@ -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" },
|
|
@@ -17,7 +18,7 @@ describe("payment voucher", () => {
|
|
|
17
18
|
const { publicKey, privateKey } = generateBase64KeyPair();
|
|
18
19
|
const invoker = new CombinationResourceInvoker({}, 100_000, publicKey, privateKey);
|
|
19
20
|
const parameters = [{ name: "city", value: "Oslo" }];
|
|
20
|
-
const voucher = invoker.createPaymentVoucher("
|
|
21
|
+
const voucher = invoker.createPaymentVoucher("org_provider", 10000, "com.example", "Weather", "forecast", parameters);
|
|
21
22
|
expect(voucher.invocationHash).toBe(invocationHash("com.example", "Weather", "forecast", parameters));
|
|
22
23
|
expect(verifyData(paymentVoucherPayload(voucher), voucher.publicKey, voucher.signature)).toBe(true);
|
|
23
24
|
expect(verifyData(voucher, voucher.publicKey, voucher.signature)).toBe(false);
|
|
@@ -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({ costMicroUsd: 10000, recipientOrganizationId: "org_provider" });
|
|
97
98
|
const pending = invoker.invoke(resource, "forecast", parameters);
|
|
98
99
|
await vi.runAllTimersAsync();
|
|
99
100
|
await pending;
|
|
@@ -103,6 +104,11 @@ describe("external invoke retries", () => {
|
|
|
103
104
|
expect(JSON.parse(bodies[0]).paymentVoucher.requestId).toBeDefined();
|
|
104
105
|
expect(JSON.parse(bodies[0]).paymentVoucher.invocationHash).toBe(invocationHash("com.example", "Weather", "forecast", parameters));
|
|
105
106
|
});
|
|
107
|
+
test("refuses a paid function whose manifest names no recipient organization, without signing a voucher", async () => {
|
|
108
|
+
const result = await createInvoker({ costMicroUsd: 10000 }).invoke(resource, "forecast", parameters);
|
|
109
|
+
expect(fetchMock).not.toHaveBeenCalled();
|
|
110
|
+
expect(result.error?.message).toContain("names no recipient organization");
|
|
111
|
+
});
|
|
106
112
|
});
|
|
107
113
|
describe("restricted functions", () => {
|
|
108
114
|
const resource = { path: "com.example", name: "Weather", metadata: { baseUrl: "https://remote.example" }, functions: [] };
|
|
@@ -139,7 +145,7 @@ describe("restricted functions", () => {
|
|
|
139
145
|
fetchMock.mockResolvedValueOnce(new Response(null, { status: 503 })).mockResolvedValueOnce(new Response(JSON.stringify({ value: 42, error: null }), { status: 200 }));
|
|
140
146
|
let nonce = 0;
|
|
141
147
|
const authorize = vi.fn(() => ({ nonce: `nonce-${nonce++}` }));
|
|
142
|
-
const invoker = createInvoker({ chainFor: async () => chain, authorize }, {
|
|
148
|
+
const invoker = createInvoker({ chainFor: async () => chain, authorize }, { costMicroUsd: 10000, recipientOrganizationId: "org_provider" });
|
|
143
149
|
const pending = invoker.invoke(resource, "forecast", parameters);
|
|
144
150
|
await vi.runAllTimersAsync();
|
|
145
151
|
await expect(pending).resolves.toEqual({ value: 42, error: null });
|
|
@@ -149,3 +155,88 @@ describe("restricted functions", () => {
|
|
|
149
155
|
expect(authorize).toHaveBeenCalledWith(chain, { resourcePath: "com.example", resourceName: "Weather", functionName: "forecast", parameters });
|
|
150
156
|
});
|
|
151
157
|
});
|
|
158
|
+
describe("inference functions", () => {
|
|
159
|
+
const ORGANIZATION = "org_provider";
|
|
160
|
+
const resource = { path: "com.example", name: "Model", metadata: { baseUrl: "https://remote.example" }, functions: [] };
|
|
161
|
+
const parameters = [{ name: "prompt", value: "Hello" }, { name: "maxTokens", value: 1000 }];
|
|
162
|
+
const manifest = { inputCostMicroUsdPerMillionTokens: 3_000_000, outputCostMicroUsdPerMillionTokens: 15_000_000, recipientOrganizationId: ORGANIZATION };
|
|
163
|
+
// 2,000 input tokens and 1,000 output tokens: 6,000 + 15,000.
|
|
164
|
+
const MAX_AMOUNT = 21_000;
|
|
165
|
+
function createInvoker(inferenceCostLimit = 100_000, fn = { kind: "inference", paymentManifest: manifest }) {
|
|
166
|
+
const { publicKey, privateKey } = generateBase64KeyPair();
|
|
167
|
+
const repository = { getResourceFunction: async () => fn };
|
|
168
|
+
return { invoker: new CombinationResourceInvoker(repository, 100_000, publicKey, privateKey, undefined, inferenceCostLimit), publicKey };
|
|
169
|
+
}
|
|
170
|
+
const json = (body, status = 200) => new Response(JSON.stringify(body), { status });
|
|
171
|
+
let fetchMock;
|
|
172
|
+
beforeEach(() => {
|
|
173
|
+
fetchMock = vi.fn();
|
|
174
|
+
vi.stubGlobal("fetch", fetchMock);
|
|
175
|
+
});
|
|
176
|
+
afterEach(() => {
|
|
177
|
+
vi.unstubAllGlobals();
|
|
178
|
+
});
|
|
179
|
+
test("inferenceCharge rounds up per million tokens", () => {
|
|
180
|
+
expect(inferenceCharge(manifest, 2_000, 1_000)).toBe(21_000);
|
|
181
|
+
expect(inferenceCharge({ inputCostMicroUsdPerMillionTokens: 150_000, outputCostMicroUsdPerMillionTokens: 0 }, 1, 1_000_000)).toBe(1);
|
|
182
|
+
expect(inferenceCharge({ inputCostMicroUsdPerMillionTokens: 0, outputCostMicroUsdPerMillionTokens: 0 }, 5, 5)).toBe(0);
|
|
183
|
+
expect(() => inferenceCharge({ inputCostMicroUsdPerMillionTokens: Number.MAX_SAFE_INTEGER, outputCostMicroUsdPerMillionTokens: 0 }, Number.MAX_SAFE_INTEGER, 0)).toThrow();
|
|
184
|
+
});
|
|
185
|
+
test("asks the provider for an estimate, then signs a voucher for its maxAmount at the advertised rates", async () => {
|
|
186
|
+
fetchMock.mockResolvedValueOnce(json({ inputTokens: 2_000, maxAmount: MAX_AMOUNT })).mockResolvedValueOnce(json({ value: "Hi", error: null, usage: { inputTokens: 1_500, outputTokens: 10 } }));
|
|
187
|
+
const { invoker, publicKey } = createInvoker();
|
|
188
|
+
const result = await invoker.invoke(resource, "complete", parameters);
|
|
189
|
+
expect(result).toEqual({ value: "Hi", error: null });
|
|
190
|
+
const [[estimateUrl, estimateInit], [runUrl, runInit]] = fetchMock.mock.calls;
|
|
191
|
+
expect(estimateUrl).toBe("https://remote.example/v1/run/resource/estimate");
|
|
192
|
+
expect(JSON.parse(estimateInit.body)).toEqual({ resourcePath: "com.example", resourceName: "Model", functionName: "complete", parameters });
|
|
193
|
+
expect(runUrl).toBe("https://remote.example/v1/run/resource");
|
|
194
|
+
const voucher = JSON.parse(runInit.body).paymentVoucher;
|
|
195
|
+
expect(voucher).toMatchObject({ kind: "inference", maxAmount: MAX_AMOUNT, maxTokens: 1000, ...manifest, publicKey });
|
|
196
|
+
expect(voucher.amount).toBeUndefined();
|
|
197
|
+
expect(Object.keys(inferenceVoucherPayload(voucher))).toEqual(["kind", "requestId", "invocationId", "maxAmount", "inputCostMicroUsdPerMillionTokens", "outputCostMicroUsdPerMillionTokens", "maxTokens", "recipientOrganizationId", "expiresAt", "invocationHash", "publicKey"]);
|
|
198
|
+
expect(verifyData(inferenceVoucherPayload(voucher), publicKey, voucher.signature)).toBe(true);
|
|
199
|
+
expect(Date.parse(voucher.expiresAt) - Date.now()).toBeGreaterThan(1000 * 60 * 5);
|
|
200
|
+
});
|
|
201
|
+
test("refuses an estimate whose maxAmount doesn't follow from the advertised rates", async () => {
|
|
202
|
+
fetchMock.mockResolvedValueOnce(json({ inputTokens: 2_000, maxAmount: MAX_AMOUNT + 1 }));
|
|
203
|
+
const result = await createInvoker().invoker.invoke(resource, "complete", parameters);
|
|
204
|
+
expect(result.error?.message).toContain("doesn't match its advertised rates");
|
|
205
|
+
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
206
|
+
});
|
|
207
|
+
test("refuses a maxAmount above the inference cost limit before signing", async () => {
|
|
208
|
+
fetchMock.mockResolvedValueOnce(json({ inputTokens: 2_000, maxAmount: MAX_AMOUNT }));
|
|
209
|
+
await expect(createInvoker(MAX_AMOUNT - 1).invoker.invoke(resource, "complete", parameters)).rejects.toThrow("inference cost limit");
|
|
210
|
+
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
211
|
+
});
|
|
212
|
+
test("fails before any network call without a positive int32 maxTokens", async () => {
|
|
213
|
+
const { invoker } = createInvoker();
|
|
214
|
+
for (const value of [undefined, 0, -1, 1.5, "10", 2 ** 31]) {
|
|
215
|
+
const call = value === undefined ? [parameters[0]] : [parameters[0], { name: "maxTokens", value }];
|
|
216
|
+
const result = await invoker.invoke(resource, "complete", call);
|
|
217
|
+
expect(result.error?.message).toContain("maxTokens");
|
|
218
|
+
}
|
|
219
|
+
expect(fetchMock).not.toHaveBeenCalled();
|
|
220
|
+
});
|
|
221
|
+
test("surfaces a failed estimate without signing a voucher", async () => {
|
|
222
|
+
fetchMock.mockResolvedValueOnce(json({ error: "Function is not a paid inference function" }, 400));
|
|
223
|
+
const result = await createInvoker().invoker.invoke(resource, "complete", parameters);
|
|
224
|
+
expect(result.error?.message).toContain("Function is not a paid inference function");
|
|
225
|
+
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
226
|
+
});
|
|
227
|
+
test("refuses per-token pricing on a function not declared as inference", async () => {
|
|
228
|
+
const result = await createInvoker(100_000, { paymentManifest: manifest }).invoker.invoke(resource, "complete", parameters);
|
|
229
|
+
expect(result.error?.message).toContain("isn't an inference function");
|
|
230
|
+
expect(fetchMock).not.toHaveBeenCalled();
|
|
231
|
+
});
|
|
232
|
+
test("returns a local inference function's value with its usage", async () => {
|
|
233
|
+
const local = {
|
|
234
|
+
path: "com.example", name: "Model", metadata: {},
|
|
235
|
+
functions: [{ name: "complete", kind: "inference", parameters: [], returnType: "string", exec: async () => ({ value: "Hi", usage: { inputTokens: 3, outputTokens: 1 } }) }],
|
|
236
|
+
};
|
|
237
|
+
const broken = { ...local, functions: [{ ...local.functions[0], exec: async () => "Hi" }] };
|
|
238
|
+
const { invoker } = createInvoker();
|
|
239
|
+
expect(await invoker.invoke(local, "complete", parameters)).toEqual({ value: "Hi", error: null, usage: { inputTokens: 3, outputTokens: 1 } });
|
|
240
|
+
expect((await invoker.invoke(broken, "complete", parameters)).error?.message).toContain("must return { value, usage");
|
|
241
|
+
});
|
|
242
|
+
});
|