stellarium-ts 0.17.0 → 0.19.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/client/operatorClient.d.ts +36 -1
- package/dist/client/operatorClient.d.ts.map +1 -1
- package/dist/client/operatorClient.js +30 -5
- package/dist/client/runTokens.d.ts +25 -0
- package/dist/client/runTokens.d.ts.map +1 -0
- package/dist/client/runTokens.js +34 -0
- package/dist/index.d.ts +3 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/resource/inference.d.ts +2 -0
- package/dist/resource/inference.d.ts.map +1 -1
- package/dist/resource/inference.js +10 -0
- package/dist/resource/invoker.d.ts +37 -3
- package/dist/resource/invoker.d.ts.map +1 -1
- package/dist/resource/invoker.js +91 -24
- 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 +86 -66
- package/dist/server.d.ts +1 -1
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +13 -1
- package/dist/service.d.ts +11 -4
- package/dist/service.d.ts.map +1 -1
- package/dist/service.js +6 -1
- package/dist/tests/integration/network.test.js +18 -14
- package/dist/tests/resource/invoker.test.js +19 -0
- package/dist/tests/routes.test.js +114 -2
- package/package.json +3 -2
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { inferenceCharge } from "./inference.js";
|
|
2
|
+
import { isInferenceVoucher } from "./invoker.js";
|
|
3
|
+
/** What the operator will capture for `usage`: its charge at the voucher's rates, capped at `maxAmount`. */
|
|
4
|
+
function cappedInferenceCharge(voucher, usage) {
|
|
5
|
+
try {
|
|
6
|
+
return Math.min(inferenceCharge(voucher, usage.inputTokens, usage.outputTokens), voucher.maxAmount);
|
|
7
|
+
}
|
|
8
|
+
catch {
|
|
9
|
+
// The charge isn't a safe integer, so it is over any maxAmount.
|
|
10
|
+
return voucher.maxAmount;
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Runs a paid call: holds the voucher's amount with the operator first, so a paid function never runs
|
|
15
|
+
* unpaid, then runs `call` and captures the voucher, or releases it for a failed or free call. Settlement is
|
|
16
|
+
* scheduled after returning, so it doesn't delay the caller, and failures go to `onSettleError`.
|
|
17
|
+
*/
|
|
18
|
+
export async function settlePaidCall(operatorClient, voucher, call, onSettleError) {
|
|
19
|
+
const authorization = await operatorClient.authorize(voucher);
|
|
20
|
+
if (!authorization.ok) {
|
|
21
|
+
return { refused: { status: authorization.status, error: authorization.error } };
|
|
22
|
+
}
|
|
23
|
+
let capture;
|
|
24
|
+
const settle = () => setTimeout(async () => {
|
|
25
|
+
try {
|
|
26
|
+
if (capture) {
|
|
27
|
+
await operatorClient.capture(capture);
|
|
28
|
+
}
|
|
29
|
+
else {
|
|
30
|
+
await operatorClient.release(voucher);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
catch (error) {
|
|
34
|
+
onSettleError(error, capture ? 'Failed to capture payment' : 'Failed to release payment');
|
|
35
|
+
}
|
|
36
|
+
}, 0);
|
|
37
|
+
let result;
|
|
38
|
+
try {
|
|
39
|
+
result = await call();
|
|
40
|
+
}
|
|
41
|
+
catch (error) {
|
|
42
|
+
settle();
|
|
43
|
+
throw error;
|
|
44
|
+
}
|
|
45
|
+
if (!result.error && !isInferenceVoucher(voucher)) {
|
|
46
|
+
capture = { voucher };
|
|
47
|
+
}
|
|
48
|
+
else if (!result.error && result.usage && isInferenceVoucher(voucher)) {
|
|
49
|
+
// The function may report more output than it was allowed; the caller only signed for maxTokens.
|
|
50
|
+
const usage = { inputTokens: result.usage.inputTokens, outputTokens: Math.min(result.usage.outputTokens, voucher.maxTokens) };
|
|
51
|
+
result.usage = usage;
|
|
52
|
+
// A free call has nothing to charge, so its hold is released instead.
|
|
53
|
+
if (cappedInferenceCharge(voucher, usage) > 0) {
|
|
54
|
+
capture = { voucher, usage };
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
settle();
|
|
58
|
+
return result;
|
|
59
|
+
}
|
package/dist/routes.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { FastifyPluginAsync } from 'fastify';
|
|
2
|
+
import { type RunTokenClaims } from './client/runTokens.js';
|
|
2
3
|
import type { Service } from './service.js';
|
|
3
4
|
export interface RateLimitConfig {
|
|
4
5
|
max: number;
|
|
@@ -9,6 +10,21 @@ interface RouteOptions {
|
|
|
9
10
|
service: Service;
|
|
10
11
|
rateLimit?: RateLimitConfig;
|
|
11
12
|
}
|
|
13
|
+
/**
|
|
14
|
+
* Who is calling `POST /v1/run`: the holder of this node's API key, or an API client with a run token.
|
|
15
|
+
* Set by the route's preValidation hook.
|
|
16
|
+
*/
|
|
17
|
+
type RunCaller = {
|
|
18
|
+
kind: 'apiKey';
|
|
19
|
+
} | ({
|
|
20
|
+
kind: 'client';
|
|
21
|
+
token: string;
|
|
22
|
+
} & RunTokenClaims);
|
|
23
|
+
declare module 'fastify' {
|
|
24
|
+
interface FastifyRequest {
|
|
25
|
+
runCaller?: RunCaller;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
12
28
|
export declare const DEFAULT_PAGE_SIZE = 100;
|
|
13
29
|
export declare const MAX_PAGE_SIZE = 500;
|
|
14
30
|
/**
|
package/dist/routes.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"routes.d.ts","sourceRoot":"","sources":["../src/routes.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,kBAAkB,EAAgC,MAAM,SAAS,CAAC;
|
|
1
|
+
{"version":3,"file":"routes.d.ts","sourceRoot":"","sources":["../src/routes.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,kBAAkB,EAAgC,MAAM,SAAS,CAAC;AAEhF,OAAO,EAAgB,KAAK,cAAc,EAAE,MAAM,uBAAuB,CAAC;AAM1E,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,cAAc,CAAC;AAK5C,MAAM,WAAW,eAAe;IAC5B,GAAG,EAAE,MAAM,CAAC;IACZ,oDAAoD;IACpD,UAAU,EAAE,MAAM,GAAG,MAAM,CAAC;CAC/B;AAED,UAAU,YAAY;IAClB,OAAO,EAAE,OAAO,CAAC;IACjB,SAAS,CAAC,EAAE,eAAe,CAAC;CAC/B;AAoCD;;;GAGG;AACH,KAAK,SAAS,GAAG;IAAE,IAAI,EAAE,QAAQ,CAAA;CAAE,GAAG,CAAC;IAAE,IAAI,EAAE,QAAQ,CAAC;IAAC,KAAK,EAAE,MAAM,CAAA;CAAE,GAAG,cAAc,CAAC,CAAC;AAE3F,OAAO,QAAQ,SAAS,CAAC;IACrB,UAAU,cAAc;QACpB,SAAS,CAAC,EAAE,SAAS,CAAC;KACzB;CACJ;AAkBD,eAAO,MAAM,iBAAiB,MAAM,CAAC;AACrC,eAAO,MAAM,aAAa,MAAM,CAAC;AAwKjC;;;;GAIG;AACH,QAAA,MAAM,MAAM,EAAE,kBAAkB,CAAC,YAAY,CAqa5C,CAAC;eAGa,MAAM"}
|
package/dist/routes.js
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
|
-
import { createHash, timingSafeEqual } from 'node:crypto';
|
|
1
|
+
import { createHash, randomUUID, timingSafeEqual } from 'node:crypto';
|
|
2
2
|
import rateLimit from '@fastify/rate-limit';
|
|
3
|
-
import { interpretParsed, parseScript } from 'requestscript';
|
|
3
|
+
import { interpretParsed, parseScript, RequestScriptError, TokenMeter } from 'requestscript';
|
|
4
|
+
import { looksLikeJwt } from './client/runTokens.js';
|
|
4
5
|
import { inferenceVoucherPayload, invocationHash, isInferenceVoucher, paymentVoucherPayload } from './resource/invoker.js';
|
|
5
|
-
import { inferenceCharge, isInferenceFunction, isInferenceManifest, parseMaxTokens } from './resource/inference.js';
|
|
6
|
+
import { estimateInputTokens, inferenceCharge, isInferenceFunction, isInferenceManifest, parseMaxTokens } from './resource/inference.js';
|
|
7
|
+
import { settlePaidCall } from './resource/settlement.js';
|
|
6
8
|
import { safeVerifyData } from './crypto/utils.js';
|
|
7
9
|
import { InvalidPublicKeyError } from './grant/service.js';
|
|
8
10
|
// Body schemas check only the shape the handlers rely on. Signed payloads (resources) are
|
|
@@ -168,26 +170,6 @@ function isValidPaymentVoucher(voucher, resourceFunction, manifest, request, org
|
|
|
168
170
|
}
|
|
169
171
|
return safeVerifyData(paymentVoucherPayload(voucher), voucher.publicKey, voucher.signature);
|
|
170
172
|
}
|
|
171
|
-
/** The function's own input-token estimate, or undefined if its estimator failed or returned something other than a count. */
|
|
172
|
-
async function estimateInputTokens(resourceFunction, request) {
|
|
173
|
-
try {
|
|
174
|
-
const inputTokens = await resourceFunction.estimateInputTokens?.(request.parameters);
|
|
175
|
-
return Number.isSafeInteger(inputTokens) && inputTokens >= 0 ? inputTokens : undefined;
|
|
176
|
-
}
|
|
177
|
-
catch {
|
|
178
|
-
return undefined;
|
|
179
|
-
}
|
|
180
|
-
}
|
|
181
|
-
/** What the operator will capture for `usage`: its charge at the voucher's rates, capped at `maxAmount`. */
|
|
182
|
-
function cappedInferenceCharge(voucher, usage) {
|
|
183
|
-
try {
|
|
184
|
-
return Math.min(inferenceCharge(voucher, usage.inputTokens, usage.outputTokens), voucher.maxAmount);
|
|
185
|
-
}
|
|
186
|
-
catch {
|
|
187
|
-
// The charge isn't a safe integer, so it is over any maxAmount.
|
|
188
|
-
return voucher.maxAmount;
|
|
189
|
-
}
|
|
190
|
-
}
|
|
191
173
|
/**
|
|
192
174
|
* Encapsulates the routes
|
|
193
175
|
* @param fastify Encapsulated Fastify Instance
|
|
@@ -210,6 +192,32 @@ const routes = async (fastify, options) => {
|
|
|
210
192
|
return reply.status(401).send({ error: 'Invalid API Key' });
|
|
211
193
|
}
|
|
212
194
|
};
|
|
195
|
+
/**
|
|
196
|
+
* Requires `Authorization: Bearer <API_KEY>` or `Bearer <run token>`, a JWT from the operator addressed to
|
|
197
|
+
* this node. Records the caller on `request.runCaller`.
|
|
198
|
+
*/
|
|
199
|
+
const requireRunAuthorization = async (request, reply) => {
|
|
200
|
+
const authorizationHeader = request.headers['authorization'];
|
|
201
|
+
if (!authorizationHeader) {
|
|
202
|
+
return reply.status(401).send({ error: 'Authorization header is required' });
|
|
203
|
+
}
|
|
204
|
+
const token = authorizationHeader.split(' ')[1];
|
|
205
|
+
const expectedApiKey = options.service.config.apiKey;
|
|
206
|
+
if (token && expectedApiKey && secretsEqual(token, expectedApiKey)) {
|
|
207
|
+
request.runCaller = { kind: 'apiKey' };
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
210
|
+
const verifier = options.service.runTokenVerifier;
|
|
211
|
+
if (token && verifier && looksLikeJwt(token)) {
|
|
212
|
+
const claims = await verifier.verify(token);
|
|
213
|
+
if (!claims) {
|
|
214
|
+
return reply.status(401).send({ error: 'Invalid run token' });
|
|
215
|
+
}
|
|
216
|
+
request.runCaller = { kind: 'client', token, ...claims };
|
|
217
|
+
return;
|
|
218
|
+
}
|
|
219
|
+
return reply.status(401).send({ error: 'Invalid API Key' });
|
|
220
|
+
};
|
|
213
221
|
// Start Meta Routes
|
|
214
222
|
fastify.get('/', async (request, reply) => {
|
|
215
223
|
return {
|
|
@@ -286,8 +294,14 @@ const routes = async (fastify, options) => {
|
|
|
286
294
|
});
|
|
287
295
|
// End Meta Routes
|
|
288
296
|
// Start Run Routes
|
|
297
|
+
/**
|
|
298
|
+
* Runs a request script. A run token from another organization's API client is billed: the operator
|
|
299
|
+
* holds the run's cost first, the script's paid calls draw on that hold, and afterwards the run is
|
|
300
|
+
* charged for the tokens the script executed at this node's listed price. Runs by the API key holder or
|
|
301
|
+
* by this node's own organization are free, and their paid calls are paid by this node's organization.
|
|
302
|
+
*/
|
|
289
303
|
// Authenticate before body validation, so unauthenticated callers learn nothing about the schema.
|
|
290
|
-
fastify.post('/run', { schema: { body: runRequestSchema }, preValidation:
|
|
304
|
+
fastify.post('/run', { schema: { body: runRequestSchema }, preValidation: requireRunAuthorization }, async (request, reply) => {
|
|
291
305
|
const runRequest = request.body;
|
|
292
306
|
let script;
|
|
293
307
|
try {
|
|
@@ -300,16 +314,52 @@ const routes = async (fastify, options) => {
|
|
|
300
314
|
if (script.declaration.kind !== 'request') {
|
|
301
315
|
return reply.status(400).send({ error: 'Invalid script' });
|
|
302
316
|
}
|
|
317
|
+
const caller = request.runCaller;
|
|
318
|
+
const billed = caller.kind === 'client' && caller.organizationId !== options.service.config.organizationId;
|
|
319
|
+
const runId = billed ? randomUUID() : undefined;
|
|
320
|
+
let resourceInvoker = options.service.resourceInvoker;
|
|
321
|
+
if (billed) {
|
|
322
|
+
const authorization = await options.service.operatorClient.authorizeRun({ token: caller.token, runId: runId, source: runRequest.source });
|
|
323
|
+
if (!authorization.ok) {
|
|
324
|
+
return reply.status(authorization.status).send({ error: authorization.error });
|
|
325
|
+
}
|
|
326
|
+
resourceInvoker = options.service.resourceInvoker.forRun({ runId: runId });
|
|
327
|
+
}
|
|
328
|
+
// Nothing waits on settlement, so it doesn't delay the response.
|
|
329
|
+
const settle = (outcome, tokens) => runId && setTimeout(async () => {
|
|
330
|
+
try {
|
|
331
|
+
if (outcome === 'capture') {
|
|
332
|
+
await options.service.operatorClient.captureRun({ runId, tokens });
|
|
333
|
+
}
|
|
334
|
+
else {
|
|
335
|
+
await options.service.operatorClient.releaseRun({ runId });
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
catch (error) {
|
|
339
|
+
fastify.log.error(error, `Failed to ${outcome} run ${runId}`);
|
|
340
|
+
}
|
|
341
|
+
}, 0);
|
|
342
|
+
const meter = new TokenMeter();
|
|
303
343
|
try {
|
|
304
344
|
const result = await interpretParsed(script, {
|
|
305
345
|
resourceResolver: options.service.resourceResolver,
|
|
306
|
-
resourceInvoker
|
|
346
|
+
resourceInvoker,
|
|
347
|
+
meter,
|
|
348
|
+
// The run token's claims, or null for an API-key run, so the name is always reserved.
|
|
349
|
+
builtins: { jwt: caller.kind === 'client' ? caller.payload : null },
|
|
307
350
|
});
|
|
308
|
-
|
|
351
|
+
settle('capture', meter.tokens);
|
|
352
|
+
return reply.status(200).send({ returnValue: result.returnValue ?? null, usage: { tokens: meter.tokens } });
|
|
309
353
|
}
|
|
310
354
|
catch (error) {
|
|
355
|
+
// A script that fails at runtime is the caller's mistake, and is charged for what it executed.
|
|
356
|
+
if (error instanceof RequestScriptError) {
|
|
357
|
+
settle('capture', meter.tokens);
|
|
358
|
+
return reply.status(422).send({ error: 'Script failed', details: error.message, usage: { tokens: meter.tokens } });
|
|
359
|
+
}
|
|
360
|
+
settle('release', meter.tokens);
|
|
311
361
|
fastify.log.error(error);
|
|
312
|
-
reply.status(500).send({ error: 'Internal server error' });
|
|
362
|
+
return reply.status(500).send({ error: 'Internal server error' });
|
|
313
363
|
}
|
|
314
364
|
});
|
|
315
365
|
/**
|
|
@@ -375,7 +425,7 @@ const routes = async (fastify, options) => {
|
|
|
375
425
|
if (maxTokens === undefined) {
|
|
376
426
|
return reply.status(400).send({ error: 'maxTokens must be a positive int32' });
|
|
377
427
|
}
|
|
378
|
-
const inputTokens = await estimateInputTokens(resourceFunction, resourceRequest);
|
|
428
|
+
const inputTokens = await estimateInputTokens(resourceFunction, resourceRequest.parameters);
|
|
379
429
|
if (inputTokens === undefined) {
|
|
380
430
|
return reply.status(500).send({ error: 'Failed to estimate input tokens' });
|
|
381
431
|
}
|
|
@@ -406,7 +456,7 @@ const routes = async (fastify, options) => {
|
|
|
406
456
|
}
|
|
407
457
|
// Re-estimated here, so inference never runs on a voucher that can't cover the expected cost.
|
|
408
458
|
if (isInferenceVoucher(paymentVoucher)) {
|
|
409
|
-
const inputTokens = await estimateInputTokens(resourceFunction, resourceRequest);
|
|
459
|
+
const inputTokens = await estimateInputTokens(resourceFunction, resourceRequest.parameters);
|
|
410
460
|
if (inputTokens === undefined) {
|
|
411
461
|
return reply.status(500).send({ error: 'Failed to estimate input tokens' });
|
|
412
462
|
}
|
|
@@ -416,44 +466,14 @@ const routes = async (fastify, options) => {
|
|
|
416
466
|
}
|
|
417
467
|
}
|
|
418
468
|
}
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
if (
|
|
422
|
-
|
|
423
|
-
if (!authorization.ok) {
|
|
424
|
-
return reply.status(authorization.status).send({ error: authorization.error });
|
|
425
|
-
}
|
|
469
|
+
const invoke = () => options.service.resourceInvoker.invoke(resource, resourceRequest.functionName, resourceRequest.parameters);
|
|
470
|
+
// A free function ignores any voucher sent with it.
|
|
471
|
+
if (!paymentManifest || !paymentVoucher) {
|
|
472
|
+
return invoke();
|
|
426
473
|
}
|
|
427
|
-
const result = await options.service.
|
|
428
|
-
if (
|
|
429
|
-
|
|
430
|
-
if (!result.error && !isInferenceVoucher(paid)) {
|
|
431
|
-
capture = { voucher: paid };
|
|
432
|
-
}
|
|
433
|
-
else if (!result.error && result.usage && isInferenceVoucher(paid)) {
|
|
434
|
-
// The function may report more output than it was allowed; the caller only signed for maxTokens.
|
|
435
|
-
const usage = { inputTokens: result.usage.inputTokens, outputTokens: Math.min(result.usage.outputTokens, paid.maxTokens) };
|
|
436
|
-
result.usage = usage;
|
|
437
|
-
// A free call has nothing to charge, so its hold is released instead.
|
|
438
|
-
if (cappedInferenceCharge(paid, usage) > 0) {
|
|
439
|
-
capture = { voucher: paid, usage };
|
|
440
|
-
}
|
|
441
|
-
}
|
|
442
|
-
// A failed or free call releases its hold. Nothing awaits this, so it doesn't delay the response,
|
|
443
|
-
// and an uncaught error here would be an unhandled rejection.
|
|
444
|
-
setTimeout(async () => {
|
|
445
|
-
try {
|
|
446
|
-
if (capture) {
|
|
447
|
-
await options.service.operatorClient.capture(capture);
|
|
448
|
-
}
|
|
449
|
-
else {
|
|
450
|
-
await options.service.operatorClient.release(paid);
|
|
451
|
-
}
|
|
452
|
-
}
|
|
453
|
-
catch (error) {
|
|
454
|
-
fastify.log.error(error, capture ? 'Failed to capture payment' : 'Failed to release payment');
|
|
455
|
-
}
|
|
456
|
-
}, 0);
|
|
474
|
+
const result = await settlePaidCall(options.service.operatorClient, paymentVoucher, invoke, (error, message) => fastify.log.error(error, message));
|
|
475
|
+
if ('refused' in result) {
|
|
476
|
+
return reply.status(result.refused.status).send({ error: result.refused.error });
|
|
457
477
|
}
|
|
458
478
|
return result;
|
|
459
479
|
});
|
package/dist/server.d.ts
CHANGED
|
@@ -20,7 +20,7 @@ export interface StellariumNodeOptions {
|
|
|
20
20
|
publicKey?: string;
|
|
21
21
|
/** Defaults to `SIGNING_PRIVATE_KEY`. */
|
|
22
22
|
privateKey?: string;
|
|
23
|
-
/** Bearer token for `POST /v1/run
|
|
23
|
+
/** Bearer token for `POST /v1/run` and the admin routes. Defaults to `API_KEY`. */
|
|
24
24
|
apiKey?: string;
|
|
25
25
|
/** Defaults to `OPERATOR_URL`. */
|
|
26
26
|
operatorUrl?: string;
|
package/dist/server.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"server.d.ts","sourceRoot":"","sources":["../src/server.ts"],"names":[],"mappings":"AAAA,OAAO,eAAe,CAAC;AACvB,OAAO,EAAE,eAAe,EAAE,MAAM,SAAS,CAAC;AAE1C,OAAO,KAAK,EAAE,UAAU,EAAE,KAAK,EAAE,MAAM,kBAAkB,CAAC;AAC1D,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,uBAAuB,CAAC;AACtD,OAAO,KAAK,EAAE,YAAY,EAAE,YAAY,EAAE,MAAM,oBAAoB,CAAC;AAErE,OAAO,EAAE,kBAAkB,EAAE,MAAM,0BAA0B,CAAC;AAC9D,OAAe,EAAE,eAAe,EAAE,MAAM,aAAa,CAAC;AACtD,OAAO,EAAiB,KAAK,UAAU,EAAgB,MAAM,cAAc,CAAC;AAI5E,eAAO,MAAM,4BAA4B,QAAS,CAAC;AACnD,eAAO,MAAM,iCAAiC,QAAgB,CAAC;AAE/D,MAAM,WAAW,qBAAqB;IAClC,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,0FAA0F;IAC1F,SAAS,CAAC,EAAE,eAAe,CAAC;IAC5B,gEAAgE;IAChE,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,wCAAwC;IACxC,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,yCAAyC;IACzC,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,
|
|
1
|
+
{"version":3,"file":"server.d.ts","sourceRoot":"","sources":["../src/server.ts"],"names":[],"mappings":"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"}
|
package/dist/server.js
CHANGED
|
@@ -56,15 +56,27 @@ export class StellariumNode {
|
|
|
56
56
|
const enrollmentToken = options.enrollmentToken ?? process.env.ENROLLMENT_TOKEN;
|
|
57
57
|
// Paid functions are paid to the node's organization, so a provider must be enrolled before it serves them.
|
|
58
58
|
// A node that only calls paid functions enrolls the same way, so its vouchers draw on its organization's balance.
|
|
59
|
+
// Any other node with an operator still reports its URL, for listing, and learns its organization, so its
|
|
60
|
+
// own organization's run tokens run free.
|
|
61
|
+
const session = () => new OperatorClient(config.operatorUrl, config.publicKey, config.privateKey).session(enrollmentToken, config.baseUrl);
|
|
59
62
|
if (hostsPaidFunctions(this.resources) || enrollmentToken) {
|
|
60
63
|
try {
|
|
61
|
-
({ organizationId: config.organizationId } = await
|
|
64
|
+
({ organizationId: config.organizationId } = await session());
|
|
62
65
|
}
|
|
63
66
|
catch (error) {
|
|
64
67
|
const hint = enrollmentToken ? '' : '. Create an enrollment token for it at https://platform.stellarium.sh and set ENROLLMENT_TOKEN';
|
|
65
68
|
throw new Error(`${error instanceof Error ? error.message : String(error)}${hint}`, { cause: error });
|
|
66
69
|
}
|
|
67
70
|
}
|
|
71
|
+
else if (config.operatorUrl) {
|
|
72
|
+
// Serving doesn't depend on it, so it doesn't hold up the start. Until it answers, run tokens from
|
|
73
|
+
// this node's own organization are refused as if billed.
|
|
74
|
+
session().then(({ organizationId }) => {
|
|
75
|
+
config.organizationId = organizationId;
|
|
76
|
+
}, (error) => {
|
|
77
|
+
fastify.log.warn(`No session with the operator, so this node can't be listed: ${error instanceof Error ? error.message : String(error)}`);
|
|
78
|
+
});
|
|
79
|
+
}
|
|
68
80
|
const db = await openDatabase(options.databasePath ?? DEFAULT_DATABASE_PATH);
|
|
69
81
|
fastify.addHook('onClose', async () => db.close());
|
|
70
82
|
const service = createService(db, this.resources, config, { approveGrant: options.approveGrant });
|
package/dist/service.d.ts
CHANGED
|
@@ -1,8 +1,11 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import type { JWTVerifyGetKey } from "jose";
|
|
2
|
+
import { ResourceResolver } from "requestscript";
|
|
2
3
|
import type { Database } from "sqlite";
|
|
3
4
|
import { PeerRepository } from "./peer/repository.js";
|
|
4
5
|
import { ResourceRepository, type StellariumResource } from "./resource/repository.js";
|
|
6
|
+
import { CombinationResourceInvoker } from "./resource/invoker.js";
|
|
5
7
|
import { OperatorClient } from "./client/operatorClient.js";
|
|
8
|
+
import { RunTokenVerifier } from "./client/runTokens.js";
|
|
6
9
|
import { type ApproveGrant, GrantService } from "./grant/service.js";
|
|
7
10
|
/** Settings a node runs with. `StellariumNode.start` fills these from its options, then the environment. */
|
|
8
11
|
export interface NodeConfig {
|
|
@@ -12,9 +15,9 @@ export interface NodeConfig {
|
|
|
12
15
|
publicKey: string;
|
|
13
16
|
/** Ed25519 PKCS#8 DER, base64url. */
|
|
14
17
|
privateKey: string;
|
|
15
|
-
/** Bearer token for `POST /v1/run
|
|
18
|
+
/** Bearer token for `POST /v1/run` and the admin routes. When unset, only run tokens are accepted, and the admin routes reject every request. */
|
|
16
19
|
apiKey?: string;
|
|
17
|
-
/** Operator base URL, including `/v1`. Required when the node hosts paid functions. */
|
|
20
|
+
/** Operator base URL, including `/v1`. Required when the node hosts paid functions, and to accept run tokens. */
|
|
18
21
|
operatorUrl?: string;
|
|
19
22
|
/**
|
|
20
23
|
* The operator organization this node is enrolled in, which is paid for its functions.
|
|
@@ -31,13 +34,17 @@ export interface Service {
|
|
|
31
34
|
peerRepository: PeerRepository;
|
|
32
35
|
resourceRepository: ResourceRepository;
|
|
33
36
|
resourceResolver: ResourceResolver;
|
|
34
|
-
resourceInvoker:
|
|
37
|
+
resourceInvoker: CombinationResourceInvoker;
|
|
35
38
|
operatorClient: OperatorClient;
|
|
36
39
|
grantService: GrantService;
|
|
40
|
+
/** Verifies the run tokens `POST /v1/run` accepts. Set when the node has an operator URL. */
|
|
41
|
+
runTokenVerifier?: RunTokenVerifier;
|
|
37
42
|
}
|
|
38
43
|
export interface ServiceOptions {
|
|
39
44
|
/** Decides `POST /v1/grants/request`. Without it, every request is refused. */
|
|
40
45
|
approveGrant?: ApproveGrant;
|
|
46
|
+
/** Replaces the operator's published keys for verifying run tokens, in tests. */
|
|
47
|
+
runTokenKeys?: JWTVerifyGetKey;
|
|
41
48
|
}
|
|
42
49
|
export declare function createService(db: Database, resources: StellariumResource[], config: NodeConfig, options?: ServiceOptions): Service;
|
|
43
50
|
//# sourceMappingURL=service.d.ts.map
|
package/dist/service.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"service.d.ts","sourceRoot":"","sources":["../src/service.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,eAAe,EAAE,gBAAgB,EAAE,MAAM,eAAe,CAAC;
|
|
1
|
+
{"version":3,"file":"service.d.ts","sourceRoot":"","sources":["../src/service.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,MAAM,CAAC;AAC5C,OAAO,EAAE,gBAAgB,EAAE,MAAM,eAAe,CAAC;AACjD,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,QAAQ,CAAC;AACvC,OAAO,EAAE,cAAc,EAAE,MAAM,sBAAsB,CAAC;AAEtD,OAAO,EAAE,kBAAkB,EAAE,KAAK,kBAAkB,EAAE,MAAM,0BAA0B,CAAC;AACvF,OAAO,EAAE,0BAA0B,EAAE,MAAM,uBAAuB,CAAC;AACnE,OAAO,EAAE,cAAc,EAAE,MAAM,4BAA4B,CAAC;AAC5D,OAAO,EAAE,gBAAgB,EAAE,MAAM,uBAAuB,CAAC;AAEzD,OAAO,EAAE,KAAK,YAAY,EAAE,YAAY,EAAE,MAAM,oBAAoB,CAAC;AAErE,4GAA4G;AAC5G,MAAM,WAAW,UAAU;IACvB,4DAA4D;IAC5D,OAAO,EAAE,MAAM,CAAC;IAChB,mCAAmC;IACnC,SAAS,EAAE,MAAM,CAAC;IAClB,qCAAqC;IACrC,UAAU,EAAE,MAAM,CAAC;IACnB,iJAAiJ;IACjJ,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,iHAAiH;IACjH,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB;;;OAGG;IACH,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,mDAAmD;IACnD,iBAAiB,EAAE,MAAM,CAAC;IAC1B,iEAAiE;IACjE,0BAA0B,EAAE,MAAM,CAAC;CACtC;AAED,MAAM,WAAW,OAAO;IACpB,MAAM,EAAE,UAAU,CAAC;IACnB,cAAc,EAAE,cAAc,CAAC;IAC/B,kBAAkB,EAAE,kBAAkB,CAAC;IACvC,gBAAgB,EAAE,gBAAgB,CAAC;IACnC,eAAe,EAAE,0BAA0B,CAAC;IAC5C,cAAc,EAAE,cAAc,CAAC;IAC/B,YAAY,EAAE,YAAY,CAAC;IAC3B,6FAA6F;IAC7F,gBAAgB,CAAC,EAAE,gBAAgB,CAAC;CACvC;AAED,MAAM,WAAW,cAAc;IAC3B,+EAA+E;IAC/E,YAAY,CAAC,EAAE,YAAY,CAAC;IAC5B,iFAAiF;IACjF,YAAY,CAAC,EAAE,eAAe,CAAC;CAClC;AAED,wBAAgB,aAAa,CAAC,EAAE,EAAE,QAAQ,EAAE,SAAS,EAAE,kBAAkB,EAAE,EAAE,MAAM,EAAE,UAAU,EAAE,OAAO,GAAE,cAAmB,GAAG,OAAO,CAwBtI"}
|
package/dist/service.js
CHANGED
|
@@ -3,14 +3,18 @@ import { CombinationResourceResolver } from "./resource/resolver.js";
|
|
|
3
3
|
import { ResourceRepository } from "./resource/repository.js";
|
|
4
4
|
import { CombinationResourceInvoker } from "./resource/invoker.js";
|
|
5
5
|
import { OperatorClient } from "./client/operatorClient.js";
|
|
6
|
+
import { RunTokenVerifier } from "./client/runTokens.js";
|
|
6
7
|
import { GrantRepository } from "./grant/repository.js";
|
|
7
8
|
import { GrantService } from "./grant/service.js";
|
|
8
9
|
export function createService(db, resources, config, options = {}) {
|
|
9
10
|
const resourceRepository = new ResourceRepository(db, resources, config.baseUrl, config.organizationId);
|
|
10
11
|
const resourceResolver = new CombinationResourceResolver(resourceRepository);
|
|
11
12
|
const grantService = new GrantService(new GrantRepository(db), config, options.approveGrant);
|
|
12
|
-
const resourceInvoker = new CombinationResourceInvoker(resourceRepository, config.costLimitMicroUsd, config.publicKey, config.privateKey, grantService, config.inferenceCostLimitMicroUsd);
|
|
13
13
|
const operatorClient = new OperatorClient(config.operatorUrl, config.publicKey, config.privateKey);
|
|
14
|
+
const resourceInvoker = new CombinationResourceInvoker(resourceRepository, config.costLimitMicroUsd, config.publicKey, config.privateKey, grantService, config.inferenceCostLimitMicroUsd, {
|
|
15
|
+
operatorClient,
|
|
16
|
+
organizationId: config.organizationId,
|
|
17
|
+
});
|
|
14
18
|
return {
|
|
15
19
|
config,
|
|
16
20
|
peerRepository: new PeerRepository(db),
|
|
@@ -19,5 +23,6 @@ export function createService(db, resources, config, options = {}) {
|
|
|
19
23
|
resourceInvoker,
|
|
20
24
|
operatorClient,
|
|
21
25
|
grantService,
|
|
26
|
+
runTokenVerifier: config.operatorUrl ? new RunTokenVerifier(config.operatorUrl, config.publicKey, options.runTokenKeys) : undefined,
|
|
22
27
|
};
|
|
23
28
|
}
|
|
@@ -141,7 +141,7 @@ describe("two-node network", { timeout: 15_000 }, () => {
|
|
|
141
141
|
baseUrl: providerUrl,
|
|
142
142
|
functions: expect.arrayContaining([expect.objectContaining({ name: "temperature", paymentManifest: { costMicroUsd: COST, recipientOrganizationId: ORGANIZATION } })]),
|
|
143
143
|
}));
|
|
144
|
-
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 } });
|
|
145
145
|
await vi.waitFor(() => expect(settlements).toHaveLength(1));
|
|
146
146
|
const [voucher] = settlements;
|
|
147
147
|
expect(authorizations).toEqual([{ voucher }]);
|
|
@@ -152,7 +152,7 @@ describe("two-node network", { timeout: 15_000 }, () => {
|
|
|
152
152
|
const first = await startCaller();
|
|
153
153
|
await first.fastify.close();
|
|
154
154
|
const second = await startCaller();
|
|
155
|
-
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 } });
|
|
156
156
|
});
|
|
157
157
|
describe("restricted functions", () => {
|
|
158
158
|
test("the caller needs a grant from the provider, then calls with it", async () => {
|
|
@@ -160,13 +160,13 @@ describe("two-node network", { timeout: 15_000 }, () => {
|
|
|
160
160
|
const refused = await run(caller.url, vaultSource);
|
|
161
161
|
expect(refused.returnValue.error.message).toContain(`requires a grant from ${providerUrl}`);
|
|
162
162
|
await caller.node.addGrant(await provider.issueGrant(callerKeys.publicKey, [vault], { lifetimeMs: HOUR }));
|
|
163
|
-
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 } });
|
|
164
164
|
});
|
|
165
165
|
test("the caller asks the provider for a grant, then calls with it", async () => {
|
|
166
166
|
const caller = await startCaller();
|
|
167
167
|
await expect(caller.node.requestGrant(providerUrl, [vault], { invite: "wrong" })).rejects.toThrow("Grant request refused");
|
|
168
168
|
await caller.node.requestGrant(providerUrl, [vault], { invite: "letmein" });
|
|
169
|
-
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 } });
|
|
170
170
|
});
|
|
171
171
|
test("the provider's admin blocks a caller over HTTP, then unblocks it", async () => {
|
|
172
172
|
const caller = await startCaller();
|
|
@@ -182,12 +182,12 @@ describe("two-node network", { timeout: 15_000 }, () => {
|
|
|
182
182
|
expect(refused.returnValue.error.message).toContain("Invalid authorization");
|
|
183
183
|
await expect(caller.node.requestGrant(providerUrl, [vault], { invite: "letmein" })).rejects.toThrow("refused");
|
|
184
184
|
expect((await adminRequest("DELETE")).status).toBe(200);
|
|
185
|
-
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 } });
|
|
186
186
|
});
|
|
187
187
|
test("grants passed at start are loaded, and revocation at the provider cuts them off", async () => {
|
|
188
188
|
const chain = await provider.issueGrant(callerKeys.publicKey, [vault], { lifetimeMs: HOUR });
|
|
189
189
|
const caller = await startCaller({ grants: [chain] });
|
|
190
|
-
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 } });
|
|
191
191
|
await provider.revokeGrant(chain[0].id);
|
|
192
192
|
const refused = await run(caller.url, vaultSource);
|
|
193
193
|
expect(refused.returnValue.error.message).toContain("Invalid authorization");
|
|
@@ -199,7 +199,7 @@ describe("two-node network", { timeout: 15_000 }, () => {
|
|
|
199
199
|
const userKeys = generateBase64KeyPair();
|
|
200
200
|
const user = await startCaller({ ...userKeys, databasePath: path.join(dir, "user.db") });
|
|
201
201
|
await user.node.addGrant(await caller.node.delegateGrant(root.id, userKeys.publicKey, [vault], { lifetimeMs: HOUR }));
|
|
202
|
-
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 } });
|
|
203
203
|
});
|
|
204
204
|
test("the refresher renews a grant past half its lifetime", async () => {
|
|
205
205
|
const fetchSpy = vi.spyOn(globalThis, "fetch");
|
|
@@ -209,7 +209,7 @@ describe("two-node network", { timeout: 15_000 }, () => {
|
|
|
209
209
|
fetchSpy.mockRestore();
|
|
210
210
|
// Well past the first copy's expiry, the renewed one still works.
|
|
211
211
|
await new Promise((resolve) => setTimeout(resolve, 500));
|
|
212
|
-
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 } });
|
|
213
213
|
});
|
|
214
214
|
});
|
|
215
215
|
test("the caller refuses a call that costs more than its limit", async () => {
|
|
@@ -220,13 +220,17 @@ describe("two-node network", { timeout: 15_000 }, () => {
|
|
|
220
220
|
expect(settlements).toEqual([]);
|
|
221
221
|
expect(authorizations).toEqual([]);
|
|
222
222
|
});
|
|
223
|
-
test("a caller with an enrollment token enrolls at startup; one without
|
|
223
|
+
test("a caller with an enrollment token enrolls at startup; one without still reports its URL", async () => {
|
|
224
224
|
sessions.length = 0;
|
|
225
|
-
await startCaller({ operatorUrl, enrollmentToken: "enr_caller" });
|
|
226
|
-
expect(sessions).toEqual([{ enrollmentToken: "enr_caller" }]);
|
|
225
|
+
const enrolled = await startCaller({ operatorUrl, enrollmentToken: "enr_caller" });
|
|
226
|
+
expect(sessions).toEqual([{ enrollmentToken: "enr_caller", baseUrl: enrolled.url }]);
|
|
227
227
|
sessions.length = 0;
|
|
228
|
-
await startCaller({ operatorUrl, databasePath: path.join(dir, "caller-2.db") });
|
|
229
|
-
expect(sessions).toEqual([]);
|
|
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 } });
|
|
230
234
|
});
|
|
231
235
|
test("the provider refuses to run a call the operator won't hold, and the caller sees why", async () => {
|
|
232
236
|
const caller = await startCaller();
|
|
@@ -242,7 +246,7 @@ describe("two-node network", { timeout: 15_000 }, () => {
|
|
|
242
246
|
test("the caller prices a call with the provider's estimate, pays for it, and the provider settles its usage", async () => {
|
|
243
247
|
const caller = await startCaller();
|
|
244
248
|
const result = await run(caller.url, completeSource);
|
|
245
|
-
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 } });
|
|
246
250
|
await vi.waitFor(() => expect(submissions).toHaveLength(1));
|
|
247
251
|
// 3 input tokens at $3/M plus 10 output tokens at $15/M, rounded up.
|
|
248
252
|
expect(submissions[0]).toMatchObject({
|
|
@@ -23,6 +23,18 @@ describe("payment voucher", () => {
|
|
|
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);
|
|
@@ -104,6 +116,13 @@ 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
|
+
});
|
|
107
126
|
test("refuses a paid function whose manifest names no recipient organization, without signing a voucher", async () => {
|
|
108
127
|
const result = await createInvoker({ costMicroUsd: 10000 }).invoke(resource, "forecast", parameters);
|
|
109
128
|
expect(fetchMock).not.toHaveBeenCalled();
|