stellarium-ts 0.16.0 → 0.18.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/client/operatorAuth.d.ts +19 -0
- package/dist/client/operatorAuth.d.ts.map +1 -0
- package/dist/client/operatorAuth.js +21 -0
- package/dist/client/operatorClient.d.ts +103 -31
- package/dist/client/operatorClient.d.ts.map +1 -1
- package/dist/client/operatorClient.js +107 -23
- package/dist/client/runTokens.d.ts +23 -0
- package/dist/client/runTokens.d.ts.map +1 -0
- package/dist/client/runTokens.js +34 -0
- package/dist/db/db.d.ts.map +1 -1
- package/dist/db/db.js +8 -5
- package/dist/index.d.ts +4 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -0
- package/dist/resource/inference.d.ts +5 -3
- package/dist/resource/inference.d.ts.map +1 -1
- package/dist/resource/inference.js +17 -7
- package/dist/resource/invoker.d.ts +48 -14
- package/dist/resource/invoker.d.ts.map +1 -1
- package/dist/resource/invoker.js +108 -38
- package/dist/resource/repository.d.ts +13 -7
- package/dist/resource/repository.d.ts.map +1 -1
- package/dist/resource/repository.js +23 -13
- package/dist/resource/settlement.d.ts +25 -0
- package/dist/resource/settlement.d.ts.map +1 -0
- package/dist/resource/settlement.js +59 -0
- package/dist/routes.d.ts +16 -0
- package/dist/routes.d.ts.map +1 -1
- package/dist/routes.js +103 -76
- package/dist/server.d.ts +11 -6
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +32 -7
- package/dist/service.d.ts +18 -6
- package/dist/service.d.ts.map +1 -1
- package/dist/service.js +8 -3
- package/dist/tests/client/operatorClient.test.js +94 -36
- package/dist/tests/db/db.test.js +4 -4
- package/dist/tests/helpers.d.ts +4 -3
- package/dist/tests/helpers.d.ts.map +1 -1
- package/dist/tests/helpers.js +8 -6
- package/dist/tests/integration/network.test.js +69 -18
- package/dist/tests/resource/invoker.test.js +33 -9
- package/dist/tests/resource/repository.test.js +9 -9
- package/dist/tests/routes.test.js +165 -40
- package/dist/tests/server.test.js +15 -15
- package/package.json +3 -2
|
@@ -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,7 +61,7 @@ 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
|
|
64
|
+
test("rebuilds a version 4 manifest table into the micro-USD, organization-recipient schema", async () => {
|
|
65
65
|
const filename = path.join(dir, "v4.db");
|
|
66
66
|
const old = await open({ filename, driver: sqlite3.Database });
|
|
67
67
|
await old.exec(`
|
|
@@ -74,8 +74,8 @@ describe("openDatabase", () => {
|
|
|
74
74
|
const db = await openDatabase(filename);
|
|
75
75
|
const functionColumns = await db.all("PRAGMA table_info(resource_function)");
|
|
76
76
|
expect(functionColumns.map(column => column.name)).toContain("kind");
|
|
77
|
-
await db.run(`INSERT INTO resource_function_payment_manifest (resource_function_id,
|
|
78
|
-
|
|
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
79
|
await db.close();
|
|
80
80
|
});
|
|
81
81
|
});
|
package/dist/tests/helpers.d.ts
CHANGED
|
@@ -4,14 +4,15 @@ 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;
|
|
12
13
|
export declare const INFERENCE_RATES: {
|
|
13
|
-
|
|
14
|
-
|
|
14
|
+
inputCostMicroUsdPerMillionTokens: number;
|
|
15
|
+
outputCostMicroUsdPerMillionTokens: number;
|
|
15
16
|
};
|
|
16
17
|
/**
|
|
17
18
|
* A fixture provider's model: paid inference `complete`, which counts one token per four prompt characters
|
|
@@ -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,8 +18,9 @@ export function testConfig(overrides = {}) {
|
|
|
17
18
|
...generateBase64KeyPair(),
|
|
18
19
|
apiKey: "secret",
|
|
19
20
|
operatorUrl: "http://operator.invalid/v1",
|
|
20
|
-
|
|
21
|
-
|
|
21
|
+
organizationId: ORGANIZATION,
|
|
22
|
+
costLimitMicroUsd: 100_000,
|
|
23
|
+
inferenceCostLimitMicroUsd: 100_000,
|
|
22
24
|
...overrides,
|
|
23
25
|
};
|
|
24
26
|
}
|
|
@@ -36,7 +38,7 @@ export function weatherResource(baseUrl = "http://127.0.0.1:3002") {
|
|
|
36
38
|
parameters: [{ name: "city", type: "string" }],
|
|
37
39
|
returnType: "int32",
|
|
38
40
|
exec: async (args) => (args.find((arg) => arg.name === "city")?.value === "Oslo" ? 12 : 20),
|
|
39
|
-
paymentManifest: {
|
|
41
|
+
paymentManifest: { costMicroUsd: COST },
|
|
40
42
|
},
|
|
41
43
|
{
|
|
42
44
|
name: "humidity",
|
|
@@ -47,7 +49,7 @@ export function weatherResource(baseUrl = "http://127.0.0.1:3002") {
|
|
|
47
49
|
],
|
|
48
50
|
};
|
|
49
51
|
}
|
|
50
|
-
export const INFERENCE_RATES = {
|
|
52
|
+
export const INFERENCE_RATES = { inputCostMicroUsdPerMillionTokens: 3_000_000, outputCostMicroUsdPerMillionTokens: 15_000_000 };
|
|
51
53
|
/**
|
|
52
54
|
* A fixture provider's model: paid inference `complete`, which counts one token per four prompt characters
|
|
53
55
|
* and answers with `maxTokens` tokens, a restricted paid copy `completePrivate`, and free inference `draft`.
|
|
@@ -64,7 +66,7 @@ export function modelResource(baseUrl = "http://127.0.0.1:3002") {
|
|
|
64
66
|
value: `echo: ${prompt(args)}`,
|
|
65
67
|
usage: { inputTokens: Math.ceil(prompt(args).length / 4), outputTokens: Number(args.find((arg) => arg.name === "maxTokens")?.value) },
|
|
66
68
|
}),
|
|
67
|
-
paymentManifest: { ...INFERENCE_RATES
|
|
69
|
+
paymentManifest: { ...INFERENCE_RATES },
|
|
68
70
|
};
|
|
69
71
|
return {
|
|
70
72
|
id: 2,
|
|
@@ -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, INFERENCE_RATES, modelResource,
|
|
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 {
|
|
@@ -35,13 +35,18 @@ function freePort() {
|
|
|
35
35
|
}
|
|
36
36
|
/**
|
|
37
37
|
* A provider node hosting the paid weather resource, a caller node that bootstraps from it,
|
|
38
|
-
* 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.
|
|
39
39
|
*/
|
|
40
40
|
// Each test starts real HTTP nodes on SQLite files, which can take several seconds on a loaded CI runner.
|
|
41
41
|
describe("two-node network", { timeout: 15_000 }, () => {
|
|
42
42
|
const servers = [];
|
|
43
43
|
const settlements = [];
|
|
44
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;
|
|
45
50
|
const callerKeys = generateBase64KeyPair();
|
|
46
51
|
let dir;
|
|
47
52
|
let operatorUrl;
|
|
@@ -76,12 +81,34 @@ describe("two-node network", { timeout: 15_000 }, () => {
|
|
|
76
81
|
dir = await mkdtemp(path.join(tmpdir(), "stellarium-network-"));
|
|
77
82
|
settlements.length = 0;
|
|
78
83
|
submissions.length = 0;
|
|
84
|
+
authorizations.length = 0;
|
|
85
|
+
releases.length = 0;
|
|
86
|
+
sessions.length = 0;
|
|
87
|
+
refuseAuthorization = undefined;
|
|
79
88
|
const operator = Fastify();
|
|
80
89
|
servers.push(operator);
|
|
81
|
-
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) => {
|
|
82
105
|
settlements.push(request.body.voucher);
|
|
83
106
|
submissions.push(request.body);
|
|
84
|
-
return { status: "
|
|
107
|
+
return { status: "captured" };
|
|
108
|
+
});
|
|
109
|
+
operator.post("/v1/payments/release", async (request) => {
|
|
110
|
+
releases.push(request.body);
|
|
111
|
+
return { status: "released" };
|
|
85
112
|
});
|
|
86
113
|
operatorUrl = `${await operator.listen({ port: 0, host: "127.0.0.1" })}/v1`;
|
|
87
114
|
const providerPort = await freePort();
|
|
@@ -112,19 +139,20 @@ describe("two-node network", { timeout: 15_000 }, () => {
|
|
|
112
139
|
expect(callerResources.resources).toContainEqual(expect.objectContaining({
|
|
113
140
|
name: "Weather",
|
|
114
141
|
baseUrl: providerUrl,
|
|
115
|
-
functions: expect.arrayContaining([expect.objectContaining({ name: "temperature", paymentManifest: {
|
|
142
|
+
functions: expect.arrayContaining([expect.objectContaining({ name: "temperature", paymentManifest: { costMicroUsd: COST, recipientOrganizationId: ORGANIZATION } })]),
|
|
116
143
|
}));
|
|
117
|
-
expect(await run(caller.url)).toEqual({ returnValue: { value: 12, error: null } });
|
|
144
|
+
expect(await run(caller.url)).toEqual({ usage: { tokens: expect.any(Number) }, returnValue: { value: 12, error: null } });
|
|
118
145
|
await vi.waitFor(() => expect(settlements).toHaveLength(1));
|
|
119
146
|
const [voucher] = settlements;
|
|
120
|
-
expect(
|
|
147
|
+
expect(authorizations).toEqual([{ voucher }]);
|
|
148
|
+
expect(voucher).toMatchObject({ amount: COST, recipientOrganizationId: ORGANIZATION, publicKey: callerKeys.publicKey });
|
|
121
149
|
expect(verifyData(paymentVoucherPayload(voucher), voucher.publicKey, voucher.signature)).toBe(true);
|
|
122
150
|
});
|
|
123
151
|
test("the caller restarts on the same database without failing", async () => {
|
|
124
152
|
const first = await startCaller();
|
|
125
153
|
await first.fastify.close();
|
|
126
154
|
const second = await startCaller();
|
|
127
|
-
expect(await run(second.url)).toEqual({ returnValue: { value: 12, error: null } });
|
|
155
|
+
expect(await run(second.url)).toEqual({ usage: { tokens: expect.any(Number) }, returnValue: { value: 12, error: null } });
|
|
128
156
|
});
|
|
129
157
|
describe("restricted functions", () => {
|
|
130
158
|
test("the caller needs a grant from the provider, then calls with it", async () => {
|
|
@@ -132,13 +160,13 @@ describe("two-node network", { timeout: 15_000 }, () => {
|
|
|
132
160
|
const refused = await run(caller.url, vaultSource);
|
|
133
161
|
expect(refused.returnValue.error.message).toContain(`requires a grant from ${providerUrl}`);
|
|
134
162
|
await caller.node.addGrant(await provider.issueGrant(callerKeys.publicKey, [vault], { lifetimeMs: HOUR }));
|
|
135
|
-
expect(await run(caller.url, vaultSource)).toEqual({ returnValue: { value: 7, error: null } });
|
|
163
|
+
expect(await run(caller.url, vaultSource)).toEqual({ usage: { tokens: expect.any(Number) }, returnValue: { value: 7, error: null } });
|
|
136
164
|
});
|
|
137
165
|
test("the caller asks the provider for a grant, then calls with it", async () => {
|
|
138
166
|
const caller = await startCaller();
|
|
139
167
|
await expect(caller.node.requestGrant(providerUrl, [vault], { invite: "wrong" })).rejects.toThrow("Grant request refused");
|
|
140
168
|
await caller.node.requestGrant(providerUrl, [vault], { invite: "letmein" });
|
|
141
|
-
expect(await run(caller.url, vaultSource)).toEqual({ returnValue: { value: 7, error: null } });
|
|
169
|
+
expect(await run(caller.url, vaultSource)).toEqual({ usage: { tokens: expect.any(Number) }, returnValue: { value: 7, error: null } });
|
|
142
170
|
});
|
|
143
171
|
test("the provider's admin blocks a caller over HTTP, then unblocks it", async () => {
|
|
144
172
|
const caller = await startCaller();
|
|
@@ -154,12 +182,12 @@ describe("two-node network", { timeout: 15_000 }, () => {
|
|
|
154
182
|
expect(refused.returnValue.error.message).toContain("Invalid authorization");
|
|
155
183
|
await expect(caller.node.requestGrant(providerUrl, [vault], { invite: "letmein" })).rejects.toThrow("refused");
|
|
156
184
|
expect((await adminRequest("DELETE")).status).toBe(200);
|
|
157
|
-
expect(await run(caller.url, vaultSource)).toEqual({ returnValue: { value: 7, error: null } });
|
|
185
|
+
expect(await run(caller.url, vaultSource)).toEqual({ usage: { tokens: expect.any(Number) }, returnValue: { value: 7, error: null } });
|
|
158
186
|
});
|
|
159
187
|
test("grants passed at start are loaded, and revocation at the provider cuts them off", async () => {
|
|
160
188
|
const chain = await provider.issueGrant(callerKeys.publicKey, [vault], { lifetimeMs: HOUR });
|
|
161
189
|
const caller = await startCaller({ grants: [chain] });
|
|
162
|
-
expect(await run(caller.url, vaultSource)).toEqual({ returnValue: { value: 7, error: null } });
|
|
190
|
+
expect(await run(caller.url, vaultSource)).toEqual({ usage: { tokens: expect.any(Number) }, returnValue: { value: 7, error: null } });
|
|
163
191
|
await provider.revokeGrant(chain[0].id);
|
|
164
192
|
const refused = await run(caller.url, vaultSource);
|
|
165
193
|
expect(refused.returnValue.error.message).toContain("Invalid authorization");
|
|
@@ -171,7 +199,7 @@ describe("two-node network", { timeout: 15_000 }, () => {
|
|
|
171
199
|
const userKeys = generateBase64KeyPair();
|
|
172
200
|
const user = await startCaller({ ...userKeys, databasePath: path.join(dir, "user.db") });
|
|
173
201
|
await user.node.addGrant(await caller.node.delegateGrant(root.id, userKeys.publicKey, [vault], { lifetimeMs: HOUR }));
|
|
174
|
-
expect(await run(user.url, vaultSource)).toEqual({ returnValue: { value: 7, error: null } });
|
|
202
|
+
expect(await run(user.url, vaultSource)).toEqual({ usage: { tokens: expect.any(Number) }, returnValue: { value: 7, error: null } });
|
|
175
203
|
});
|
|
176
204
|
test("the refresher renews a grant past half its lifetime", async () => {
|
|
177
205
|
const fetchSpy = vi.spyOn(globalThis, "fetch");
|
|
@@ -181,30 +209,53 @@ describe("two-node network", { timeout: 15_000 }, () => {
|
|
|
181
209
|
fetchSpy.mockRestore();
|
|
182
210
|
// Well past the first copy's expiry, the renewed one still works.
|
|
183
211
|
await new Promise((resolve) => setTimeout(resolve, 500));
|
|
184
|
-
expect(await run(caller.url, vaultSource)).toEqual({ returnValue: { value: 7, error: null } });
|
|
212
|
+
expect(await run(caller.url, vaultSource)).toEqual({ usage: { tokens: expect.any(Number) }, returnValue: { value: 7, error: null } });
|
|
185
213
|
});
|
|
186
214
|
});
|
|
187
215
|
test("the caller refuses a call that costs more than its limit", async () => {
|
|
188
|
-
const caller = await startCaller({
|
|
216
|
+
const caller = await startCaller({ costLimitMicroUsd: COST - 1 });
|
|
189
217
|
const result = await run(caller.url);
|
|
190
218
|
expect(result.returnValue.value).toBeNull();
|
|
191
219
|
expect(result.returnValue.error.message).toContain("exceeds the cost limit");
|
|
192
220
|
expect(settlements).toEqual([]);
|
|
221
|
+
expect(authorizations).toEqual([]);
|
|
222
|
+
});
|
|
223
|
+
test("a caller with an enrollment token enrolls at startup; one without still reports its URL", async () => {
|
|
224
|
+
sessions.length = 0;
|
|
225
|
+
const enrolled = await startCaller({ operatorUrl, enrollmentToken: "enr_caller" });
|
|
226
|
+
expect(sessions).toEqual([{ enrollmentToken: "enr_caller", baseUrl: enrolled.url }]);
|
|
227
|
+
sessions.length = 0;
|
|
228
|
+
const other = await startCaller({ operatorUrl, databasePath: path.join(dir, "caller-2.db") });
|
|
229
|
+
await vi.waitFor(() => expect(sessions).toEqual([{ baseUrl: other.url }]));
|
|
230
|
+
});
|
|
231
|
+
test("a node without paid functions or a token starts without waiting for an unreachable operator", async () => {
|
|
232
|
+
const caller = await startCaller({ operatorUrl: "http://127.0.0.1:1/v1", databasePath: path.join(dir, "caller-3.db") });
|
|
233
|
+
expect(await run(caller.url)).toMatchObject({ returnValue: { value: 12, error: null } });
|
|
234
|
+
});
|
|
235
|
+
test("the provider refuses to run a call the operator won't hold, and the caller sees why", async () => {
|
|
236
|
+
const caller = await startCaller();
|
|
237
|
+
refuseAuthorization = { status: 402, error: "Insufficient balance" };
|
|
238
|
+
const result = await run(caller.url);
|
|
239
|
+
expect(result.returnValue.value).toBeNull();
|
|
240
|
+
expect(result.returnValue.error.message).toContain("Insufficient balance");
|
|
241
|
+
expect(authorizations).toHaveLength(1);
|
|
242
|
+
expect(settlements).toEqual([]);
|
|
243
|
+
expect(releases).toEqual([]);
|
|
193
244
|
});
|
|
194
245
|
describe("inference functions", () => {
|
|
195
246
|
test("the caller prices a call with the provider's estimate, pays for it, and the provider settles its usage", async () => {
|
|
196
247
|
const caller = await startCaller();
|
|
197
248
|
const result = await run(caller.url, completeSource);
|
|
198
|
-
expect(result).toEqual({ returnValue: { value: "echo: Hello world!", error: null } });
|
|
249
|
+
expect(result).toEqual({ usage: { tokens: expect.any(Number) }, returnValue: { value: "echo: Hello world!", error: null } });
|
|
199
250
|
await vi.waitFor(() => expect(submissions).toHaveLength(1));
|
|
200
251
|
// 3 input tokens at $3/M plus 10 output tokens at $15/M, rounded up.
|
|
201
252
|
expect(submissions[0]).toMatchObject({
|
|
202
|
-
voucher: { kind: "inference", maxAmount: 159, maxTokens: 10, ...INFERENCE_RATES,
|
|
253
|
+
voucher: { kind: "inference", maxAmount: 159, maxTokens: 10, ...INFERENCE_RATES, recipientOrganizationId: ORGANIZATION, publicKey: callerKeys.publicKey },
|
|
203
254
|
usage: { inputTokens: 3, outputTokens: 10 },
|
|
204
255
|
});
|
|
205
256
|
});
|
|
206
257
|
test("the caller refuses a call whose maxAmount is over its inference cost limit", async () => {
|
|
207
|
-
const caller = await startCaller({
|
|
258
|
+
const caller = await startCaller({ inferenceCostLimitMicroUsd: 158 });
|
|
208
259
|
const result = await run(caller.url, completeSource);
|
|
209
260
|
expect(result.returnValue.error.message).toContain("exceeds the inference cost limit");
|
|
210
261
|
expect(submissions).toEqual([]);
|
|
@@ -18,11 +18,23 @@ describe("payment voucher", () => {
|
|
|
18
18
|
const { publicKey, privateKey } = generateBase64KeyPair();
|
|
19
19
|
const invoker = new CombinationResourceInvoker({}, 100_000, publicKey, privateKey);
|
|
20
20
|
const parameters = [{ name: "city", value: "Oslo" }];
|
|
21
|
-
const voucher = invoker.createPaymentVoucher("
|
|
21
|
+
const voucher = invoker.createPaymentVoucher("org_provider", 10000, "com.example", "Weather", "forecast", parameters);
|
|
22
22
|
expect(voucher.invocationHash).toBe(invocationHash("com.example", "Weather", "forecast", parameters));
|
|
23
23
|
expect(verifyData(paymentVoucherPayload(voucher), voucher.publicKey, voucher.signature)).toBe(true);
|
|
24
24
|
expect(verifyData(voucher, voucher.publicKey, voucher.signature)).toBe(false);
|
|
25
25
|
});
|
|
26
|
+
test("signs a run id last, and only when the voucher has one", () => {
|
|
27
|
+
const { publicKey, privateKey } = generateBase64KeyPair();
|
|
28
|
+
const invoker = new CombinationResourceInvoker({}, 100_000, publicKey, privateKey);
|
|
29
|
+
const parameters = [{ name: "city", value: "Oslo" }];
|
|
30
|
+
const plain = invoker.createPaymentVoucher("org_provider", 10000, "com.example", "Weather", "forecast", parameters);
|
|
31
|
+
const forRun = invoker.createPaymentVoucher("org_provider", 10000, "com.example", "Weather", "forecast", parameters, "run-1");
|
|
32
|
+
// Vouchers without a run sign the same bytes as before run ids existed.
|
|
33
|
+
expect(Object.keys(paymentVoucherPayload(plain))).toEqual(["requestId", "invocationId", "amount", "recipientOrganizationId", "expiresAt", "invocationHash", "publicKey"]);
|
|
34
|
+
expect(Object.keys(paymentVoucherPayload(forRun)).at(-1)).toBe("runId");
|
|
35
|
+
expect(verifyData(paymentVoucherPayload(forRun), publicKey, forRun.signature)).toBe(true);
|
|
36
|
+
expect(verifyData(paymentVoucherPayload({ ...forRun, runId: "run-2" }), publicKey, forRun.signature)).toBe(false);
|
|
37
|
+
});
|
|
26
38
|
test("invocationHash changes with the function name or parameters", () => {
|
|
27
39
|
const parameters = [{ name: "city", value: "Oslo" }];
|
|
28
40
|
const hash = invocationHash("com.example", "Weather", "forecast", parameters);
|
|
@@ -94,7 +106,7 @@ describe("external invoke retries", () => {
|
|
|
94
106
|
});
|
|
95
107
|
test("reuses the same payment voucher on every attempt", async () => {
|
|
96
108
|
fetchMock.mockResolvedValueOnce(status(503)).mockResolvedValueOnce(status(503)).mockResolvedValueOnce(ok());
|
|
97
|
-
const invoker = createInvoker({
|
|
109
|
+
const invoker = createInvoker({ costMicroUsd: 10000, recipientOrganizationId: "org_provider" });
|
|
98
110
|
const pending = invoker.invoke(resource, "forecast", parameters);
|
|
99
111
|
await vi.runAllTimersAsync();
|
|
100
112
|
await pending;
|
|
@@ -104,6 +116,18 @@ describe("external invoke retries", () => {
|
|
|
104
116
|
expect(JSON.parse(bodies[0]).paymentVoucher.requestId).toBeDefined();
|
|
105
117
|
expect(JSON.parse(bodies[0]).paymentVoucher.invocationHash).toBe(invocationHash("com.example", "Weather", "forecast", parameters));
|
|
106
118
|
});
|
|
119
|
+
test("a billed run's calls carry its run id, and its hold replaces the cost limit", async () => {
|
|
120
|
+
fetchMock.mockResolvedValueOnce(ok());
|
|
121
|
+
const invoker = createInvoker({ costMicroUsd: 500_000, recipientOrganizationId: "org_provider" });
|
|
122
|
+
await invoker.forRun({ runId: "run-1" }).invoke(resource, "forecast", parameters);
|
|
123
|
+
expect(JSON.parse(fetchMock.mock.calls[0][1].body).paymentVoucher).toMatchObject({ amount: 500_000, runId: "run-1" });
|
|
124
|
+
await expect(invoker.invoke(resource, "forecast", parameters)).rejects.toThrow("exceeds the cost limit");
|
|
125
|
+
});
|
|
126
|
+
test("refuses a paid function whose manifest names no recipient organization, without signing a voucher", async () => {
|
|
127
|
+
const result = await createInvoker({ costMicroUsd: 10000 }).invoke(resource, "forecast", parameters);
|
|
128
|
+
expect(fetchMock).not.toHaveBeenCalled();
|
|
129
|
+
expect(result.error?.message).toContain("names no recipient organization");
|
|
130
|
+
});
|
|
107
131
|
});
|
|
108
132
|
describe("restricted functions", () => {
|
|
109
133
|
const resource = { path: "com.example", name: "Weather", metadata: { baseUrl: "https://remote.example" }, functions: [] };
|
|
@@ -140,7 +164,7 @@ describe("restricted functions", () => {
|
|
|
140
164
|
fetchMock.mockResolvedValueOnce(new Response(null, { status: 503 })).mockResolvedValueOnce(new Response(JSON.stringify({ value: 42, error: null }), { status: 200 }));
|
|
141
165
|
let nonce = 0;
|
|
142
166
|
const authorize = vi.fn(() => ({ nonce: `nonce-${nonce++}` }));
|
|
143
|
-
const invoker = createInvoker({ chainFor: async () => chain, authorize }, {
|
|
167
|
+
const invoker = createInvoker({ chainFor: async () => chain, authorize }, { costMicroUsd: 10000, recipientOrganizationId: "org_provider" });
|
|
144
168
|
const pending = invoker.invoke(resource, "forecast", parameters);
|
|
145
169
|
await vi.runAllTimersAsync();
|
|
146
170
|
await expect(pending).resolves.toEqual({ value: 42, error: null });
|
|
@@ -151,10 +175,10 @@ describe("restricted functions", () => {
|
|
|
151
175
|
});
|
|
152
176
|
});
|
|
153
177
|
describe("inference functions", () => {
|
|
154
|
-
const
|
|
178
|
+
const ORGANIZATION = "org_provider";
|
|
155
179
|
const resource = { path: "com.example", name: "Model", metadata: { baseUrl: "https://remote.example" }, functions: [] };
|
|
156
180
|
const parameters = [{ name: "prompt", value: "Hello" }, { name: "maxTokens", value: 1000 }];
|
|
157
|
-
const manifest = {
|
|
181
|
+
const manifest = { inputCostMicroUsdPerMillionTokens: 3_000_000, outputCostMicroUsdPerMillionTokens: 15_000_000, recipientOrganizationId: ORGANIZATION };
|
|
158
182
|
// 2,000 input tokens and 1,000 output tokens: 6,000 + 15,000.
|
|
159
183
|
const MAX_AMOUNT = 21_000;
|
|
160
184
|
function createInvoker(inferenceCostLimit = 100_000, fn = { kind: "inference", paymentManifest: manifest }) {
|
|
@@ -173,9 +197,9 @@ describe("inference functions", () => {
|
|
|
173
197
|
});
|
|
174
198
|
test("inferenceCharge rounds up per million tokens", () => {
|
|
175
199
|
expect(inferenceCharge(manifest, 2_000, 1_000)).toBe(21_000);
|
|
176
|
-
expect(inferenceCharge({
|
|
177
|
-
expect(inferenceCharge({
|
|
178
|
-
expect(() => inferenceCharge({
|
|
200
|
+
expect(inferenceCharge({ inputCostMicroUsdPerMillionTokens: 150_000, outputCostMicroUsdPerMillionTokens: 0 }, 1, 1_000_000)).toBe(1);
|
|
201
|
+
expect(inferenceCharge({ inputCostMicroUsdPerMillionTokens: 0, outputCostMicroUsdPerMillionTokens: 0 }, 5, 5)).toBe(0);
|
|
202
|
+
expect(() => inferenceCharge({ inputCostMicroUsdPerMillionTokens: Number.MAX_SAFE_INTEGER, outputCostMicroUsdPerMillionTokens: 0 }, Number.MAX_SAFE_INTEGER, 0)).toThrow();
|
|
179
203
|
});
|
|
180
204
|
test("asks the provider for an estimate, then signs a voucher for its maxAmount at the advertised rates", async () => {
|
|
181
205
|
fetchMock.mockResolvedValueOnce(json({ inputTokens: 2_000, maxAmount: MAX_AMOUNT })).mockResolvedValueOnce(json({ value: "Hi", error: null, usage: { inputTokens: 1_500, outputTokens: 10 } }));
|
|
@@ -189,7 +213,7 @@ describe("inference functions", () => {
|
|
|
189
213
|
const voucher = JSON.parse(runInit.body).paymentVoucher;
|
|
190
214
|
expect(voucher).toMatchObject({ kind: "inference", maxAmount: MAX_AMOUNT, maxTokens: 1000, ...manifest, publicKey });
|
|
191
215
|
expect(voucher.amount).toBeUndefined();
|
|
192
|
-
expect(Object.keys(inferenceVoucherPayload(voucher))).toEqual(["kind", "requestId", "invocationId", "maxAmount", "
|
|
216
|
+
expect(Object.keys(inferenceVoucherPayload(voucher))).toEqual(["kind", "requestId", "invocationId", "maxAmount", "inputCostMicroUsdPerMillionTokens", "outputCostMicroUsdPerMillionTokens", "maxTokens", "recipientOrganizationId", "expiresAt", "invocationHash", "publicKey"]);
|
|
193
217
|
expect(verifyData(inferenceVoucherPayload(voucher), publicKey, voucher.signature)).toBe(true);
|
|
194
218
|
expect(Date.parse(voucher.expiresAt) - Date.now()).toBeGreaterThan(1000 * 60 * 5);
|
|
195
219
|
});
|