stellarium-ts 0.17.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.
@@ -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
  /**
@@ -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;AAMhF,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,cAAc,CAAC;AAM5C,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;AAoDD,eAAO,MAAM,iBAAiB,MAAM,CAAC;AACrC,eAAO,MAAM,aAAa,MAAM,CAAC;AA4LjC;;;;GAIG;AACH,QAAA,MAAM,MAAM,EAAE,kBAAkB,CAAC,YAAY,CAgY5C,CAAC;eAGa,MAAM"}
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,CAma5C,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: requireApiKey }, async (request, reply) => {
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,50 @@ 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: options.service.resourceInvoker
346
+ resourceInvoker,
347
+ meter,
307
348
  });
308
- reply.status(200).send({ returnValue: result.returnValue ?? null });
349
+ settle('capture', meter.tokens);
350
+ return reply.status(200).send({ returnValue: result.returnValue ?? null, usage: { tokens: meter.tokens } });
309
351
  }
310
352
  catch (error) {
353
+ // A script that fails at runtime is the caller's mistake, and is charged for what it executed.
354
+ if (error instanceof RequestScriptError) {
355
+ settle('capture', meter.tokens);
356
+ return reply.status(422).send({ error: 'Script failed', details: error.message, usage: { tokens: meter.tokens } });
357
+ }
358
+ settle('release', meter.tokens);
311
359
  fastify.log.error(error);
312
- reply.status(500).send({ error: 'Internal server error' });
360
+ return reply.status(500).send({ error: 'Internal server error' });
313
361
  }
314
362
  });
315
363
  /**
@@ -375,7 +423,7 @@ const routes = async (fastify, options) => {
375
423
  if (maxTokens === undefined) {
376
424
  return reply.status(400).send({ error: 'maxTokens must be a positive int32' });
377
425
  }
378
- const inputTokens = await estimateInputTokens(resourceFunction, resourceRequest);
426
+ const inputTokens = await estimateInputTokens(resourceFunction, resourceRequest.parameters);
379
427
  if (inputTokens === undefined) {
380
428
  return reply.status(500).send({ error: 'Failed to estimate input tokens' });
381
429
  }
@@ -406,7 +454,7 @@ const routes = async (fastify, options) => {
406
454
  }
407
455
  // Re-estimated here, so inference never runs on a voucher that can't cover the expected cost.
408
456
  if (isInferenceVoucher(paymentVoucher)) {
409
- const inputTokens = await estimateInputTokens(resourceFunction, resourceRequest);
457
+ const inputTokens = await estimateInputTokens(resourceFunction, resourceRequest.parameters);
410
458
  if (inputTokens === undefined) {
411
459
  return reply.status(500).send({ error: 'Failed to estimate input tokens' });
412
460
  }
@@ -416,44 +464,14 @@ const routes = async (fastify, options) => {
416
464
  }
417
465
  }
418
466
  }
419
- // Hold the call's price on the payer's balance before running it, so a paid function never runs unpaid.
420
- const paid = paymentManifest && paymentVoucher ? paymentVoucher : undefined;
421
- if (paid) {
422
- const authorization = await options.service.operatorClient.authorize(paid);
423
- if (!authorization.ok) {
424
- return reply.status(authorization.status).send({ error: authorization.error });
425
- }
467
+ const invoke = () => options.service.resourceInvoker.invoke(resource, resourceRequest.functionName, resourceRequest.parameters);
468
+ // A free function ignores any voucher sent with it.
469
+ if (!paymentManifest || !paymentVoucher) {
470
+ return invoke();
426
471
  }
427
- const result = await options.service.resourceInvoker.invoke(resource, resourceRequest.functionName, resourceRequest.parameters);
428
- if (paid) {
429
- let capture;
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);
472
+ const result = await settlePaidCall(options.service.operatorClient, paymentVoucher, invoke, (error, message) => fastify.log.error(error, message));
473
+ if ('refused' in result) {
474
+ return reply.status(result.refused.status).send({ error: result.refused.error });
457
475
  }
458
476
  return result;
459
477
  });
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`. Defaults to `API_KEY`. */
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;
@@ -1 +1 @@
1
- {"version":3,"file":"server.d.ts","sourceRoot":"","sources":["../src/server.ts"],"names":[],"mappings":"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"}
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 new OperatorClient(config.operatorUrl, config.publicKey, config.privateKey).session(enrollmentToken));
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 { ResourceInvoker, ResourceResolver } from "requestscript";
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`. When unset, `/v1/run` rejects every request. */
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: 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
@@ -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;AAClE,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;AAEvF,OAAO,EAAE,cAAc,EAAE,MAAM,4BAA4B,CAAC;AAE5D,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,oFAAoF;IACpF,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,uFAAuF;IACvF,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,eAAe,CAAC;IACjC,cAAc,EAAE,cAAc,CAAC;IAC/B,YAAY,EAAE,YAAY,CAAC;CAC9B;AAED,MAAM,WAAW,cAAc;IAC3B,+EAA+E;IAC/E,YAAY,CAAC,EAAE,YAAY,CAAC;CAC/B;AAED,wBAAgB,aAAa,CAAC,EAAE,EAAE,QAAQ,EAAE,SAAS,EAAE,kBAAkB,EAAE,EAAE,MAAM,EAAE,UAAU,EAAE,OAAO,GAAE,cAAmB,GAAG,OAAO,CAoBtI"}
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 doesn't contact the operator", async () => {
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();