stellarium-ts 0.16.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 +68 -31
- 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 +8 -5
- package/dist/index.d.ts +2 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/resource/inference.d.ts +3 -3
- package/dist/resource/inference.d.ts.map +1 -1
- package/dist/resource/inference.js +7 -7
- package/dist/resource/invoker.d.ts +13 -13
- package/dist/resource/invoker.d.ts.map +1 -1
- package/dist/resource/invoker.js +26 -23
- 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/routes.d.ts.map +1 -1
- package/dist/routes.js +46 -37
- package/dist/server.d.ts +10 -5
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +20 -7
- package/dist/service.d.ts +7 -2
- 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 +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 +56 -9
- package/dist/tests/resource/invoker.test.js +14 -9
- package/dist/tests/resource/repository.test.js +9 -9
- package/dist/tests/routes.test.js +71 -38
- package/dist/tests/server.test.js +15 -15
- 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,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,12 +139,13 @@ 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
144
|
expect(await run(caller.url)).toEqual({ 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 () => {
|
|
@@ -185,11 +213,30 @@ describe("two-node network", { timeout: 15_000 }, () => {
|
|
|
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 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([]);
|
|
193
240
|
});
|
|
194
241
|
describe("inference functions", () => {
|
|
195
242
|
test("the caller prices a call with the provider's estimate, pays for it, and the provider settles its usage", async () => {
|
|
@@ -199,12 +246,12 @@ describe("two-node network", { timeout: 15_000 }, () => {
|
|
|
199
246
|
await vi.waitFor(() => expect(submissions).toHaveLength(1));
|
|
200
247
|
// 3 input tokens at $3/M plus 10 output tokens at $15/M, rounded up.
|
|
201
248
|
expect(submissions[0]).toMatchObject({
|
|
202
|
-
voucher: { kind: "inference", maxAmount: 159, maxTokens: 10, ...INFERENCE_RATES,
|
|
249
|
+
voucher: { kind: "inference", maxAmount: 159, maxTokens: 10, ...INFERENCE_RATES, recipientOrganizationId: ORGANIZATION, publicKey: callerKeys.publicKey },
|
|
203
250
|
usage: { inputTokens: 3, outputTokens: 10 },
|
|
204
251
|
});
|
|
205
252
|
});
|
|
206
253
|
test("the caller refuses a call whose maxAmount is over its inference cost limit", async () => {
|
|
207
|
-
const caller = await startCaller({
|
|
254
|
+
const caller = await startCaller({ inferenceCostLimitMicroUsd: 158 });
|
|
208
255
|
const result = await run(caller.url, completeSource);
|
|
209
256
|
expect(result.returnValue.error.message).toContain("exceeds the inference cost limit");
|
|
210
257
|
expect(submissions).toEqual([]);
|
|
@@ -18,7 +18,7 @@ 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);
|
|
@@ -94,7 +94,7 @@ describe("external invoke retries", () => {
|
|
|
94
94
|
});
|
|
95
95
|
test("reuses the same payment voucher on every attempt", async () => {
|
|
96
96
|
fetchMock.mockResolvedValueOnce(status(503)).mockResolvedValueOnce(status(503)).mockResolvedValueOnce(ok());
|
|
97
|
-
const invoker = createInvoker({
|
|
97
|
+
const invoker = createInvoker({ costMicroUsd: 10000, recipientOrganizationId: "org_provider" });
|
|
98
98
|
const pending = invoker.invoke(resource, "forecast", parameters);
|
|
99
99
|
await vi.runAllTimersAsync();
|
|
100
100
|
await pending;
|
|
@@ -104,6 +104,11 @@ describe("external invoke retries", () => {
|
|
|
104
104
|
expect(JSON.parse(bodies[0]).paymentVoucher.requestId).toBeDefined();
|
|
105
105
|
expect(JSON.parse(bodies[0]).paymentVoucher.invocationHash).toBe(invocationHash("com.example", "Weather", "forecast", parameters));
|
|
106
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
|
+
});
|
|
107
112
|
});
|
|
108
113
|
describe("restricted functions", () => {
|
|
109
114
|
const resource = { path: "com.example", name: "Weather", metadata: { baseUrl: "https://remote.example" }, functions: [] };
|
|
@@ -140,7 +145,7 @@ describe("restricted functions", () => {
|
|
|
140
145
|
fetchMock.mockResolvedValueOnce(new Response(null, { status: 503 })).mockResolvedValueOnce(new Response(JSON.stringify({ value: 42, error: null }), { status: 200 }));
|
|
141
146
|
let nonce = 0;
|
|
142
147
|
const authorize = vi.fn(() => ({ nonce: `nonce-${nonce++}` }));
|
|
143
|
-
const invoker = createInvoker({ chainFor: async () => chain, authorize }, {
|
|
148
|
+
const invoker = createInvoker({ chainFor: async () => chain, authorize }, { costMicroUsd: 10000, recipientOrganizationId: "org_provider" });
|
|
144
149
|
const pending = invoker.invoke(resource, "forecast", parameters);
|
|
145
150
|
await vi.runAllTimersAsync();
|
|
146
151
|
await expect(pending).resolves.toEqual({ value: 42, error: null });
|
|
@@ -151,10 +156,10 @@ describe("restricted functions", () => {
|
|
|
151
156
|
});
|
|
152
157
|
});
|
|
153
158
|
describe("inference functions", () => {
|
|
154
|
-
const
|
|
159
|
+
const ORGANIZATION = "org_provider";
|
|
155
160
|
const resource = { path: "com.example", name: "Model", metadata: { baseUrl: "https://remote.example" }, functions: [] };
|
|
156
161
|
const parameters = [{ name: "prompt", value: "Hello" }, { name: "maxTokens", value: 1000 }];
|
|
157
|
-
const manifest = {
|
|
162
|
+
const manifest = { inputCostMicroUsdPerMillionTokens: 3_000_000, outputCostMicroUsdPerMillionTokens: 15_000_000, recipientOrganizationId: ORGANIZATION };
|
|
158
163
|
// 2,000 input tokens and 1,000 output tokens: 6,000 + 15,000.
|
|
159
164
|
const MAX_AMOUNT = 21_000;
|
|
160
165
|
function createInvoker(inferenceCostLimit = 100_000, fn = { kind: "inference", paymentManifest: manifest }) {
|
|
@@ -173,9 +178,9 @@ describe("inference functions", () => {
|
|
|
173
178
|
});
|
|
174
179
|
test("inferenceCharge rounds up per million tokens", () => {
|
|
175
180
|
expect(inferenceCharge(manifest, 2_000, 1_000)).toBe(21_000);
|
|
176
|
-
expect(inferenceCharge({
|
|
177
|
-
expect(inferenceCharge({
|
|
178
|
-
expect(() => inferenceCharge({
|
|
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();
|
|
179
184
|
});
|
|
180
185
|
test("asks the provider for an estimate, then signs a voucher for its maxAmount at the advertised rates", async () => {
|
|
181
186
|
fetchMock.mockResolvedValueOnce(json({ inputTokens: 2_000, maxAmount: MAX_AMOUNT })).mockResolvedValueOnce(json({ value: "Hi", error: null, usage: { inputTokens: 1_500, outputTokens: 10 } }));
|
|
@@ -189,7 +194,7 @@ describe("inference functions", () => {
|
|
|
189
194
|
const voucher = JSON.parse(runInit.body).paymentVoucher;
|
|
190
195
|
expect(voucher).toMatchObject({ kind: "inference", maxAmount: MAX_AMOUNT, maxTokens: 1000, ...manifest, publicKey });
|
|
191
196
|
expect(voucher.amount).toBeUndefined();
|
|
192
|
-
expect(Object.keys(inferenceVoucherPayload(voucher))).toEqual(["kind", "requestId", "invocationId", "maxAmount", "
|
|
197
|
+
expect(Object.keys(inferenceVoucherPayload(voucher))).toEqual(["kind", "requestId", "invocationId", "maxAmount", "inputCostMicroUsdPerMillionTokens", "outputCostMicroUsdPerMillionTokens", "maxTokens", "recipientOrganizationId", "expiresAt", "invocationHash", "publicKey"]);
|
|
193
198
|
expect(verifyData(inferenceVoucherPayload(voucher), publicKey, voucher.signature)).toBe(true);
|
|
194
199
|
expect(Date.parse(voucher.expiresAt) - Date.now()).toBeGreaterThan(1000 * 60 * 5);
|
|
195
200
|
});
|
|
@@ -3,7 +3,7 @@ import { signData } from "../../crypto/utils.js";
|
|
|
3
3
|
import { openDatabase } from "../../db/db.js";
|
|
4
4
|
import { PeerRepository } from "../../peer/repository.js";
|
|
5
5
|
import { resourceAnnouncementPayload, ResourceRepository } from "../../resource/repository.js";
|
|
6
|
-
import { generateBase64KeyPair,
|
|
6
|
+
import { generateBase64KeyPair, ORGANIZATION, weatherResource } from "../helpers.js";
|
|
7
7
|
const SELF = "http://self.example";
|
|
8
8
|
const PROVIDER = "http://provider.example";
|
|
9
9
|
function resource(name, functions, baseUrl = PROVIDER) {
|
|
@@ -52,16 +52,16 @@ describe("ResourceRepository", () => {
|
|
|
52
52
|
expect((await repository.getResourceFunction("com.provider", "Forecast", "tomorrow"))?.description).toBe("Tomorrow's forecast");
|
|
53
53
|
});
|
|
54
54
|
test("keeps inference functions' kind and per-token pricing", async () => {
|
|
55
|
-
const pricing = {
|
|
55
|
+
const pricing = { inputCostMicroUsdPerMillionTokens: 3_000_000, outputCostMicroUsdPerMillionTokens: 0, recipientOrganizationId: ORGANIZATION };
|
|
56
56
|
await repository.learn(resource("Model", [
|
|
57
57
|
{ name: "complete", kind: "inference", parameters: [{ name: "maxTokens", type: "int32" }], paymentManifest: pricing },
|
|
58
|
-
{ name: "count", paymentManifest: {
|
|
58
|
+
{ name: "count", paymentManifest: { costMicroUsd: 5, recipientOrganizationId: ORGANIZATION } },
|
|
59
59
|
]));
|
|
60
60
|
const complete = await repository.getResourceFunction("com.provider", "Model", "complete");
|
|
61
61
|
expect(complete).toMatchObject({ kind: "inference", paymentManifest: pricing });
|
|
62
62
|
const count = await repository.getResourceFunction("com.provider", "Model", "count");
|
|
63
63
|
expect(count?.kind).toBeUndefined();
|
|
64
|
-
expect(count?.paymentManifest).toEqual({ id: expect.any(Number),
|
|
64
|
+
expect(count?.paymentManifest).toEqual({ id: expect.any(Number), costMicroUsd: 5, recipientOrganizationId: ORGANIZATION });
|
|
65
65
|
const functions = (await repository.getResource("com.provider.Model"))?.functions;
|
|
66
66
|
expect(functions.find(fn => fn.name === "complete")).toMatchObject({ kind: "inference", paymentManifest: pricing });
|
|
67
67
|
});
|
|
@@ -102,12 +102,12 @@ describe("ResourceRepository", () => {
|
|
|
102
102
|
test("returns each function's own payment manifest", async () => {
|
|
103
103
|
await repository.learn(resource("Forecast", [
|
|
104
104
|
{ name: "free" },
|
|
105
|
-
{ name: "paid", paymentManifest: {
|
|
105
|
+
{ name: "paid", paymentManifest: { costMicroUsd: 250, recipientOrganizationId: ORGANIZATION } },
|
|
106
106
|
]));
|
|
107
107
|
const found = await repository.getResource("com.provider.Forecast");
|
|
108
108
|
expect(found.metadata).toEqual({ baseUrl: PROVIDER });
|
|
109
109
|
expect(found.functions.find(f => f.name === "free")?.paymentManifest).toBeUndefined();
|
|
110
|
-
expect(found.functions.find(f => f.name === "paid")?.paymentManifest).toMatchObject({
|
|
110
|
+
expect(found.functions.find(f => f.name === "paid")?.paymentManifest).toMatchObject({ costMicroUsd: 250, recipientOrganizationId: ORGANIZATION });
|
|
111
111
|
});
|
|
112
112
|
test("prefers the internal resource", async () => {
|
|
113
113
|
const found = await repository.getResource("com.example.Weather");
|
|
@@ -133,13 +133,13 @@ describe("ResourceRepository", () => {
|
|
|
133
133
|
test("adds new functions, parameters and payment manifests to a known resource", async () => {
|
|
134
134
|
await save([resource("Forecast", [{ name: "today" }])]);
|
|
135
135
|
const result = await save([resource("Forecast", [
|
|
136
|
-
{ name: "today", parameters: [{ name: "city", type: "string" }], paymentManifest: {
|
|
136
|
+
{ name: "today", parameters: [{ name: "city", type: "string" }], paymentManifest: { costMicroUsd: 100, recipientOrganizationId: ORGANIZATION } },
|
|
137
137
|
{ name: "tomorrow" },
|
|
138
138
|
])]);
|
|
139
139
|
expect(result).toEqual({ ok: true, rejected: [] });
|
|
140
140
|
const today = await repository.getResourceFunction("com.provider", "Forecast", "today");
|
|
141
141
|
expect(today?.parameters).toEqual([{ name: "city", type: "string" }]);
|
|
142
|
-
expect(today?.paymentManifest).toMatchObject({
|
|
142
|
+
expect(today?.paymentManifest).toMatchObject({ costMicroUsd: 100 });
|
|
143
143
|
expect(await repository.getResourceFunction("com.provider", "Forecast", "tomorrow")).toBeDefined();
|
|
144
144
|
});
|
|
145
145
|
test("drops descriptions that aren't strings and websites that aren't http(s) URLs", async () => {
|
|
@@ -156,7 +156,7 @@ describe("ResourceRepository", () => {
|
|
|
156
156
|
expect([radar.website, radar.description]).toEqual(["https://radar.example", "Rain radar"]);
|
|
157
157
|
});
|
|
158
158
|
test("removes functions and payment manifests the peer no longer advertises", async () => {
|
|
159
|
-
await save([resource("Forecast", [{ name: "today", paymentManifest: {
|
|
159
|
+
await save([resource("Forecast", [{ name: "today", paymentManifest: { costMicroUsd: 100, recipientOrganizationId: ORGANIZATION } }, { name: "old" }])]);
|
|
160
160
|
await save([resource("Forecast", [{ name: "today" }])]);
|
|
161
161
|
expect((await repository.getResourceFunction("com.provider", "Forecast", "today"))?.paymentManifest).toBeUndefined();
|
|
162
162
|
expect(await repository.getResourceFunction("com.provider", "Forecast", "old")).toBeUndefined();
|