@north-light/sdk 0.3.624 → 0.3.625
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/index.d.ts +1 -1
- package/dist/resources/inference-endpoint.d.ts +61 -36
- package/dist/resources/inference-endpoint.js +330 -95
- package/dist/resources/inference-pricing.d.ts +18 -0
- package/dist/resources/inference-pricing.js +48 -0
- package/dist/skill/northlight/SKILL.md +2 -2
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +5 -5
package/dist/index.d.ts
CHANGED
|
@@ -33,7 +33,7 @@ export type { AuthorizeNonce, Connection, ConnectionStore, Identity, OAuth2Optio
|
|
|
33
33
|
export type { IdTokenClaims } from '@north-light/identity';
|
|
34
34
|
export { cursorFromRequest, forwardRunEvents } from './resources/forward.js';
|
|
35
35
|
export { INFERENCE_SCOPE, inferenceEndpoint } from './resources/inference-endpoint.js';
|
|
36
|
-
export type { InferenceEndpointOptions,
|
|
36
|
+
export type { InferenceEndpointOptions, InferenceRung, InferenceSettlement, SettlementStore } from './resources/inference-endpoint.js';
|
|
37
37
|
export { RUN_NOTICE_SCOPE, RunNoticeError, verifyRunNotice } from './resources/run-notice.js';
|
|
38
38
|
export type { RunNotice, VerifyRunNoticeOptions } from './resources/run-notice.js';
|
|
39
39
|
export type { NodeStreamEvent, NodeStreamEventListener, NodeStreamEventType } from './resources/node-stream.js';
|
|
@@ -1,50 +1,75 @@
|
|
|
1
|
+
import { type Units } from './inference-pricing.js';
|
|
1
2
|
/** The scope an inference token carries. */
|
|
2
3
|
export declare const INFERENCE_SCOPE = "crtr:llm";
|
|
3
4
|
export type InferenceRung = 'ultra' | 'strong' | 'medium' | 'light';
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
5
|
+
/** One model call's usage report to the directory (`POST /v1/billing/settle-model`). `list_usd` is the Anthropic list price of the served model; `cost_usd` is what Northlight paid for the call: 0 when the team Claude pool served it, `list_usd` when the app's API key did. */
|
|
6
|
+
export interface InferenceSettlement {
|
|
7
|
+
admission_id: string;
|
|
8
|
+
served_model: string;
|
|
9
|
+
units: Units;
|
|
10
|
+
list_usd: number;
|
|
11
|
+
cost_usd: number;
|
|
12
|
+
vendor_ref: string;
|
|
11
13
|
}
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
14
|
+
/**
|
|
15
|
+
* The app's durable queue of usage reports the directory has not yet accepted, kept in the app's own database. The helper saves a
|
|
16
|
+
* report before its first post and removes it on the directory's 2xx. `settle-model` is idempotent, so a repeat, or a second
|
|
17
|
+
* instance sweeping the same row, is harmless.
|
|
18
|
+
*/
|
|
19
|
+
export interface SettlementStore {
|
|
20
|
+
/** Stores a new report, due at once. */
|
|
21
|
+
save(settlement: InferenceSettlement): Promise<void>;
|
|
22
|
+
/** Deletes the report for `admissionId`; no error when it is already gone. */
|
|
23
|
+
remove(admissionId: string): Promise<void>;
|
|
24
|
+
/** Up to `limit` stored reports whose next attempt is at or before `now`, earliest first, with the posts already tried. */
|
|
25
|
+
due(now: Date, limit: number): Promise<Array<{
|
|
26
|
+
settlement: InferenceSettlement;
|
|
27
|
+
attempts: number;
|
|
28
|
+
}>>;
|
|
29
|
+
/** Sets the report's attempt count to `attempts` and its next attempt to `at`. */
|
|
30
|
+
retryLater(admissionId: string, at: Date, attempts: number): Promise<void>;
|
|
23
31
|
}
|
|
24
32
|
export interface InferenceEndpointOptions {
|
|
25
|
-
/** The directory issuer. */
|
|
33
|
+
/** The directory issuer. Admission and settlement are posted to its `/v1/billing` routes. */
|
|
26
34
|
issuer: string;
|
|
27
|
-
/** The endpoint base URL the app registered, for example `https://context.northlight.ai/v1/inference`. The token's `aud` must equal it exactly; its path is where
|
|
35
|
+
/** The endpoint base URL the app registered, for example `https://context.northlight.ai/v1/inference`. The token's `aud` must equal it exactly; its path is where `handle` is mounted. The endpoint serves `POST <baseURL>/anthropic/v1/messages`. */
|
|
28
36
|
baseURL: string;
|
|
29
|
-
/** The app's own principal, `app:<id>`; the token's
|
|
37
|
+
/** The app's own principal, `app:<id>`; the token's actor chain must contain it. */
|
|
30
38
|
app: string;
|
|
31
|
-
/**
|
|
32
|
-
|
|
33
|
-
/** Strength to model:
|
|
39
|
+
/** The app's Anthropic API key. It is used only for a call the directory admits as card-paid (no `pool` in the admission), and is never logged or returned. */
|
|
40
|
+
apiKey: string;
|
|
41
|
+
/** Strength to model: `anthropic/<model id>` for each rung the endpoint serves. A request naming any other model is refused. Every model needs a list price in this SDK; the constructor throws for one that has none. */
|
|
34
42
|
rungs: Partial<Record<InferenceRung, string>>;
|
|
35
|
-
/**
|
|
36
|
-
|
|
37
|
-
/**
|
|
43
|
+
/** The app's `OAuth2Client.directoryToken`: a directory API token as the app, re-minted when called with the token the directory just refused. */
|
|
44
|
+
directoryToken: (rejected?: string) => Promise<string>;
|
|
45
|
+
/** Where usage reports wait until the directory accepts them. */
|
|
46
|
+
settlements: SettlementStore;
|
|
47
|
+
/** Called with ids, model and the vendor's token counts only: never the prompt, the answer or a key. */
|
|
48
|
+
log?: (entry: Record<string, unknown>) => void;
|
|
49
|
+
/** The `fetch` used for the directory, its keys and the upstream call (default: the global `fetch`). */
|
|
38
50
|
fetch?: typeof fetch;
|
|
39
51
|
}
|
|
40
52
|
/**
|
|
41
|
-
* The server side of an app
|
|
42
|
-
*
|
|
43
|
-
*
|
|
44
|
-
*
|
|
45
|
-
*
|
|
46
|
-
*
|
|
47
|
-
*
|
|
48
|
-
*
|
|
53
|
+
* The server side of an app whose model calls Northlight pays for or bills: a handler for `POST <baseURL>/anthropic/v1/messages`
|
|
54
|
+
* that serves a person's runtime the Anthropic Messages API. Mount `handle` at the path of `baseURL`; call `sweep` on a timer.
|
|
55
|
+
*
|
|
56
|
+
* The request carries a directory token (`x-api-key` or `Authorization: Bearer`) that the handler verifies (issuer, `aud` =
|
|
57
|
+
* `baseURL`, expiry, scope `crtr:llm`, the app in the actor chain); a token that does not hold is 401 and nothing is sent upstream.
|
|
58
|
+
* A server-side tool or service tier is 400, a model outside `rungs` 404, a method other than POST 405. Otherwise the directory
|
|
59
|
+
* decides, per call and from current state, whether the call is admitted: a refusal reaches the runtime as a 402 carrying the
|
|
60
|
+
* directory's `payment` body, and a directory that cannot be reached or answers anything unexpected is 503 with nothing sent upstream.
|
|
61
|
+
*
|
|
62
|
+
* The upstream comes from the admission answer. An answer carrying `pool` (Northlight pays) sends the call to the pool at
|
|
63
|
+
* `pool.url` with `pool.token`, and the call settles at list price with `cost_usd` 0. An answer without it (the person pays) sends
|
|
64
|
+
* the call to Anthropic with the app's `apiKey`, and `cost_usd` equals the list price. There is no other route: an unreachable or
|
|
65
|
+
* refusing pool answers the caller 502 or the pool's own status (a 429 keeps its `Retry-After`) and never reaches the API key.
|
|
66
|
+
*
|
|
67
|
+
* The answer streams back with `Crtr-Charge-Id` and calculated `crtr_charge` on its terminal event (or JSON body), without waiting
|
|
68
|
+
* for settlement. Usage is read off the stream and reported to the directory at the list price of the model the vendor reports it
|
|
69
|
+
* served (the requested one when it reports none), through `settlements`. A failed upstream answer reports nothing, and neither does
|
|
70
|
+
* an answer from a served model with no list price (logged with its usage): its admission stays unsettled at zero.
|
|
49
71
|
*/
|
|
50
|
-
export declare function inferenceEndpoint(options: InferenceEndpointOptions):
|
|
72
|
+
export declare function inferenceEndpoint(options: InferenceEndpointOptions): {
|
|
73
|
+
handle: (request: Request) => Promise<Response>;
|
|
74
|
+
sweep: (now?: Date) => Promise<void>;
|
|
75
|
+
};
|
|
@@ -1,149 +1,384 @@
|
|
|
1
1
|
import { JwksCache, verifyCallToken } from '@north-light/identity';
|
|
2
|
+
import { hasListPrice, listUsd, longCacheWriteOf, pricedModel, unitsOf } from './inference-pricing.js';
|
|
2
3
|
/** The scope an inference token carries. */
|
|
3
4
|
export const INFERENCE_SCOPE = 'crtr:llm';
|
|
4
|
-
const
|
|
5
|
+
const VENDOR_URL = 'https://api.anthropic.com';
|
|
6
|
+
const MESSAGES_PATH = 'v1/messages';
|
|
7
|
+
const POOL_MESSAGES_PATH = 'v1/llm/anthropic/messages';
|
|
8
|
+
const DIRECTORY_TIMEOUT_MS = 10_000;
|
|
9
|
+
/** Posts made in the request's own lifetime before the stored report is left to `sweep`. */
|
|
10
|
+
const IMMEDIATE_ATTEMPTS = 3;
|
|
11
|
+
const SWEEP_BATCH = 50;
|
|
5
12
|
const STRIPPED_REQUEST_HEADERS = ['host', 'connection', 'keep-alive', 'transfer-encoding', 'te', 'upgrade', 'content-length', 'authorization', 'x-api-key', 'cookie', 'proxy-authorization'];
|
|
6
13
|
// The body is decoded and re-streamed, so these describe the upstream wire form, not what we send.
|
|
7
14
|
const STRIPPED_RESPONSE_HEADERS = ['connection', 'keep-alive', 'transfer-encoding', 'content-encoding', 'content-length'];
|
|
8
|
-
|
|
9
|
-
|
|
15
|
+
const object = (value) => typeof value === 'object' && value !== null && !Array.isArray(value) ? value : undefined;
|
|
16
|
+
const refuse = (status, type, message) => Response.json({ type: 'error', error: { type, message } }, { status });
|
|
17
|
+
const backoffMs = (attempts) => Math.min(2 ** attempts * 1000, 60 * 60 * 1000);
|
|
18
|
+
/** The bearer token of an Anthropic-style request. */
|
|
19
|
+
const bearerOf = (request) => request.headers.get('x-api-key') ?? request.headers.get('authorization')?.replace(/^Bearer\s+/i, '');
|
|
20
|
+
/** Whether a request asks for something the vendor bills beyond token counts, which settlement does not price: a server-side tool
|
|
21
|
+
* (any tool with a type other than `custom`) or a service tier or speed setting. */
|
|
22
|
+
function hasBilledFeature(body) {
|
|
23
|
+
if (body === undefined)
|
|
24
|
+
return false;
|
|
25
|
+
if (body.speed !== undefined)
|
|
26
|
+
return true;
|
|
27
|
+
if (body.service_tier !== undefined && body.service_tier !== 'auto')
|
|
28
|
+
return true;
|
|
29
|
+
return Array.isArray(body.tools) && body.tools.some((tool) => {
|
|
30
|
+
const type = object(tool)?.type;
|
|
31
|
+
return type !== undefined && type !== 'custom';
|
|
32
|
+
});
|
|
10
33
|
}
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
function tokenOf(request, header) {
|
|
15
|
-
if (header === 'x-api-key')
|
|
16
|
-
return request.headers.get('x-api-key') ?? undefined;
|
|
17
|
-
return /^Bearer ([^\s]+)$/.exec(request.headers.get('authorization') ?? '')?.[1];
|
|
18
|
-
}
|
|
19
|
-
/** The vendor's `usage` of a completed answer: the body's for JSON, the events' merged for a server-sent stream. */
|
|
20
|
-
async function usageOf(body, contentType) {
|
|
21
|
-
const object = (value) => typeof value === 'object' && value !== null && !Array.isArray(value) ? value : undefined;
|
|
34
|
+
/** What the vendor reported for a finished answer. `usage` is the merged `usage` objects of a server-sent stream (or the JSON body's),
|
|
35
|
+
* undefined when the answer carried none; a stream cut off keeps what arrived. */
|
|
36
|
+
async function reportOf(body, contentType) {
|
|
22
37
|
const reader = body.getReader();
|
|
23
38
|
const decoder = new TextDecoder();
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
39
|
+
const stream = contentType.includes('text/event-stream');
|
|
40
|
+
let usage;
|
|
41
|
+
let model;
|
|
42
|
+
let pending = '';
|
|
43
|
+
let stopped = false;
|
|
44
|
+
const take = (value) => {
|
|
45
|
+
if (object(value)?.type === 'message_stop')
|
|
46
|
+
stopped = true;
|
|
47
|
+
const found = object(object(value)?.usage) ?? object(object(object(value)?.message)?.usage);
|
|
48
|
+
if (found !== undefined)
|
|
49
|
+
usage = { ...usage, ...found };
|
|
50
|
+
const named = object(value)?.model ?? object(object(value)?.message)?.model;
|
|
51
|
+
if (typeof named === 'string')
|
|
52
|
+
model = named;
|
|
53
|
+
};
|
|
54
|
+
const takeLine = (line) => {
|
|
55
|
+
if (!line.startsWith('data:'))
|
|
56
|
+
return;
|
|
57
|
+
try {
|
|
58
|
+
take(JSON.parse(line.slice(5).trim()));
|
|
59
|
+
}
|
|
60
|
+
catch { /* A line that is not JSON carries no usage. */ }
|
|
61
|
+
};
|
|
62
|
+
try {
|
|
41
63
|
for (;;) {
|
|
42
64
|
const { done, value } = await reader.read();
|
|
43
65
|
if (done)
|
|
44
66
|
break;
|
|
45
67
|
pending += decoder.decode(value, { stream: true });
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
68
|
+
if (stream) {
|
|
69
|
+
const lines = pending.split('\n');
|
|
70
|
+
pending = lines.pop();
|
|
71
|
+
lines.forEach(takeLine);
|
|
72
|
+
}
|
|
49
73
|
}
|
|
50
|
-
take(pending);
|
|
51
|
-
return merged;
|
|
52
74
|
}
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
return undefined;
|
|
62
|
-
try {
|
|
63
|
-
return object(object(JSON.parse(text))?.usage);
|
|
64
|
-
}
|
|
65
|
-
catch {
|
|
66
|
-
return undefined;
|
|
75
|
+
catch { /* The upstream stream broke: the caller's side going away does not end this read. */ }
|
|
76
|
+
if (stream)
|
|
77
|
+
takeLine(pending);
|
|
78
|
+
else if (contentType.includes('json')) {
|
|
79
|
+
try {
|
|
80
|
+
take(JSON.parse(pending));
|
|
81
|
+
}
|
|
82
|
+
catch { /* No usable body, no usage. */ }
|
|
67
83
|
}
|
|
84
|
+
return { usage, model, complete: stream ? stopped : usage !== undefined };
|
|
85
|
+
}
|
|
86
|
+
/** Only the terminal SSE frame waits for pricing; text and tools keep streaming. */
|
|
87
|
+
function chargedBody(body, contentType, charge) {
|
|
88
|
+
const decoder = new TextDecoder();
|
|
89
|
+
const encoder = new TextEncoder();
|
|
90
|
+
const stream = contentType.includes('text/event-stream');
|
|
91
|
+
if (!stream && !contentType.includes('json'))
|
|
92
|
+
return body;
|
|
93
|
+
let pending = '';
|
|
94
|
+
const frame = async (text) => {
|
|
95
|
+
const lines = text.split('\n');
|
|
96
|
+
for (let i = 0; i < lines.length; i++) {
|
|
97
|
+
if (!lines[i].startsWith('data:'))
|
|
98
|
+
continue;
|
|
99
|
+
let value;
|
|
100
|
+
try {
|
|
101
|
+
value = object(JSON.parse(lines[i].slice(5).trim()));
|
|
102
|
+
}
|
|
103
|
+
catch {
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
106
|
+
if (value?.type !== 'message_stop')
|
|
107
|
+
continue;
|
|
108
|
+
const calculated = await charge;
|
|
109
|
+
if (calculated !== undefined)
|
|
110
|
+
lines[i] = `data: ${JSON.stringify({ ...value, crtr_charge: calculated })}`;
|
|
111
|
+
}
|
|
112
|
+
return lines.join('\n');
|
|
113
|
+
};
|
|
114
|
+
return body.pipeThrough(new TransformStream({
|
|
115
|
+
async transform(chunk, controller) {
|
|
116
|
+
pending += decoder.decode(chunk, { stream: true });
|
|
117
|
+
if (!stream)
|
|
118
|
+
return;
|
|
119
|
+
for (;;) {
|
|
120
|
+
const boundary = /\r?\n\r?\n/.exec(pending);
|
|
121
|
+
if (boundary === null)
|
|
122
|
+
break;
|
|
123
|
+
const end = boundary.index + boundary[0].length;
|
|
124
|
+
const text = pending.slice(0, end);
|
|
125
|
+
pending = pending.slice(end);
|
|
126
|
+
controller.enqueue(encoder.encode(await frame(text)));
|
|
127
|
+
}
|
|
128
|
+
},
|
|
129
|
+
async flush(controller) {
|
|
130
|
+
pending += decoder.decode();
|
|
131
|
+
if (stream)
|
|
132
|
+
pending = await frame(pending);
|
|
133
|
+
else if (contentType.includes('json')) {
|
|
134
|
+
const calculated = await charge;
|
|
135
|
+
if (calculated !== undefined)
|
|
136
|
+
pending = JSON.stringify({ ...object(JSON.parse(pending)), crtr_charge: calculated });
|
|
137
|
+
}
|
|
138
|
+
controller.enqueue(encoder.encode(pending));
|
|
139
|
+
},
|
|
140
|
+
}));
|
|
68
141
|
}
|
|
69
142
|
/**
|
|
70
|
-
* The server side of an app
|
|
71
|
-
*
|
|
72
|
-
*
|
|
73
|
-
*
|
|
74
|
-
*
|
|
75
|
-
*
|
|
76
|
-
*
|
|
77
|
-
*
|
|
143
|
+
* The server side of an app whose model calls Northlight pays for or bills: a handler for `POST <baseURL>/anthropic/v1/messages`
|
|
144
|
+
* that serves a person's runtime the Anthropic Messages API. Mount `handle` at the path of `baseURL`; call `sweep` on a timer.
|
|
145
|
+
*
|
|
146
|
+
* The request carries a directory token (`x-api-key` or `Authorization: Bearer`) that the handler verifies (issuer, `aud` =
|
|
147
|
+
* `baseURL`, expiry, scope `crtr:llm`, the app in the actor chain); a token that does not hold is 401 and nothing is sent upstream.
|
|
148
|
+
* A server-side tool or service tier is 400, a model outside `rungs` 404, a method other than POST 405. Otherwise the directory
|
|
149
|
+
* decides, per call and from current state, whether the call is admitted: a refusal reaches the runtime as a 402 carrying the
|
|
150
|
+
* directory's `payment` body, and a directory that cannot be reached or answers anything unexpected is 503 with nothing sent upstream.
|
|
151
|
+
*
|
|
152
|
+
* The upstream comes from the admission answer. An answer carrying `pool` (Northlight pays) sends the call to the pool at
|
|
153
|
+
* `pool.url` with `pool.token`, and the call settles at list price with `cost_usd` 0. An answer without it (the person pays) sends
|
|
154
|
+
* the call to Anthropic with the app's `apiKey`, and `cost_usd` equals the list price. There is no other route: an unreachable or
|
|
155
|
+
* refusing pool answers the caller 502 or the pool's own status (a 429 keeps its `Retry-After`) and never reaches the API key.
|
|
156
|
+
*
|
|
157
|
+
* The answer streams back with `Crtr-Charge-Id` and calculated `crtr_charge` on its terminal event (or JSON body), without waiting
|
|
158
|
+
* for settlement. Usage is read off the stream and reported to the directory at the list price of the model the vendor reports it
|
|
159
|
+
* served (the requested one when it reports none), through `settlements`. A failed upstream answer reports nothing, and neither does
|
|
160
|
+
* an answer from a served model with no list price (logged with its usage): its admission stays unsettled at zero.
|
|
78
161
|
*/
|
|
79
162
|
export function inferenceEndpoint(options) {
|
|
80
163
|
const upstreamFetch = options.fetch ?? ((input, init) => fetch(input, init));
|
|
164
|
+
const log = (entry) => options.log?.(entry);
|
|
81
165
|
const mount = new URL(options.baseURL).pathname.replace(/\/+$/, '');
|
|
82
|
-
const
|
|
166
|
+
const messagesPath = `${mount}/anthropic/${MESSAGES_PATH}`;
|
|
167
|
+
const served = new Set();
|
|
168
|
+
for (const spec of Object.values(options.rungs)) {
|
|
169
|
+
const model = spec.startsWith('anthropic/') ? spec.slice('anthropic/'.length) : undefined;
|
|
170
|
+
if (model === undefined || !hasListPrice(model))
|
|
171
|
+
throw new Error(`inferenceEndpoint: rung model ${spec} must be anthropic/<model> with a list price`);
|
|
172
|
+
served.add(model);
|
|
173
|
+
}
|
|
83
174
|
if (served.size === 0)
|
|
84
175
|
throw new Error('inferenceEndpoint: rungs must name at least one model');
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
176
|
+
const issuerBase = options.issuer.replace(/\/+$/, '');
|
|
177
|
+
const jwks = new JwksCache(options.issuer, (input, init) => upstreamFetch(input, init), `${issuerBase}/.well-known/jwks.json`);
|
|
178
|
+
const vendorRefPrefix = options.app.replace(/^app:/, '');
|
|
179
|
+
/** A POST to the directory as the app; a 401 hands the refused token back and retries once with a fresh one. */
|
|
180
|
+
const directoryPost = async (path, body) => {
|
|
181
|
+
const send = (token) => upstreamFetch(new URL(path, issuerBase), {
|
|
182
|
+
method: 'POST',
|
|
183
|
+
headers: { accept: 'application/json', 'content-type': 'application/json', authorization: `Bearer ${token}` },
|
|
184
|
+
body: JSON.stringify(body),
|
|
185
|
+
signal: AbortSignal.timeout(DIRECTORY_TIMEOUT_MS),
|
|
186
|
+
});
|
|
187
|
+
const token = await options.directoryToken();
|
|
188
|
+
let response = await send(token);
|
|
189
|
+
if (response.status === 401)
|
|
190
|
+
response = await send(await options.directoryToken(token));
|
|
191
|
+
return { status: response.status, json: await response.json().catch(() => undefined) };
|
|
192
|
+
};
|
|
193
|
+
/** Asks the directory to admit one model call. Anything but an explicit admission or a payment refusal is a refusal to serve. */
|
|
194
|
+
const admit = async (token, model) => {
|
|
195
|
+
let answer;
|
|
196
|
+
try {
|
|
197
|
+
answer = await directoryPost('/v1/billing/admit-model', { token, model });
|
|
198
|
+
}
|
|
199
|
+
catch {
|
|
200
|
+
return { kind: 'unavailable' };
|
|
201
|
+
}
|
|
202
|
+
const body = object(answer.json);
|
|
203
|
+
if (answer.status === 200) {
|
|
204
|
+
const { status, admission_id, charge_id, pool } = body ?? {};
|
|
205
|
+
if (status !== 'ok' || typeof admission_id !== 'string' || typeof charge_id !== 'string')
|
|
206
|
+
return { kind: 'unavailable' };
|
|
207
|
+
if (pool === undefined)
|
|
208
|
+
return { kind: 'admitted', admissionId: admission_id, chargeId: charge_id, pool: undefined };
|
|
209
|
+
const { url, token: poolToken } = object(pool) ?? {};
|
|
210
|
+
// A pool the helper cannot read is not an admission for the API key.
|
|
211
|
+
return typeof url === 'string' && url !== '' && typeof poolToken === 'string' && poolToken !== ''
|
|
212
|
+
? { kind: 'admitted', admissionId: admission_id, chargeId: charge_id, pool: { url: url.replace(/\/+$/, ''), token: poolToken } }
|
|
213
|
+
: { kind: 'unavailable' };
|
|
214
|
+
}
|
|
215
|
+
const error = object(body?.error);
|
|
216
|
+
if (answer.status === 402) {
|
|
217
|
+
const payment = object(object(error?.details)?.payment);
|
|
218
|
+
return payment && typeof payment.reason === 'string' && typeof payment.message === 'string'
|
|
219
|
+
? { kind: 'refused', payment: {
|
|
220
|
+
reason: payment.reason,
|
|
221
|
+
message: payment.message,
|
|
222
|
+
pay_url: typeof payment.pay_url === 'string' ? payment.pay_url : null,
|
|
223
|
+
reset_at: typeof payment.reset_at === 'string' ? payment.reset_at : null,
|
|
224
|
+
} }
|
|
225
|
+
: { kind: 'unavailable' };
|
|
226
|
+
}
|
|
227
|
+
return answer.status === 403 && typeof error?.message === 'string' ? { kind: 'forbidden', message: error.message } : { kind: 'unavailable' };
|
|
228
|
+
};
|
|
229
|
+
/** True once the directory answered 2xx (settled or duplicate) and the stored report is removed. */
|
|
230
|
+
const deliver = async (settlement) => {
|
|
231
|
+
let status;
|
|
232
|
+
try {
|
|
233
|
+
status = (await directoryPost('/v1/billing/settle-model', settlement)).status;
|
|
234
|
+
}
|
|
235
|
+
catch (error) {
|
|
236
|
+
log({ settle_error: String(error), admission: settlement.admission_id });
|
|
237
|
+
return false;
|
|
238
|
+
}
|
|
239
|
+
// Only a 2xx means the directory priced the charge. Anything else keeps the report, which `sweep` posts again.
|
|
240
|
+
if (status < 200 || status >= 300) {
|
|
241
|
+
log({ settle_status: status, admission: settlement.admission_id });
|
|
242
|
+
return false;
|
|
243
|
+
}
|
|
244
|
+
await options.settlements.remove(settlement.admission_id);
|
|
245
|
+
return true;
|
|
246
|
+
};
|
|
247
|
+
const record = async (settlement) => {
|
|
248
|
+
await options.settlements.save(settlement);
|
|
249
|
+
for (let attempt = 0; attempt < IMMEDIATE_ATTEMPTS; attempt++) {
|
|
250
|
+
if (await deliver(settlement))
|
|
251
|
+
return;
|
|
252
|
+
if (attempt < IMMEDIATE_ATTEMPTS - 1)
|
|
253
|
+
await new Promise((resolve) => setTimeout(resolve, 200 * 2 ** attempt));
|
|
254
|
+
}
|
|
255
|
+
};
|
|
256
|
+
const sweep = async (now = new Date()) => {
|
|
257
|
+
for (const { settlement, attempts } of await options.settlements.due(now, SWEEP_BATCH)) {
|
|
258
|
+
if (await deliver(settlement))
|
|
259
|
+
continue;
|
|
260
|
+
await options.settlements.retryLater(settlement.admission_id, new Date(now.getTime() + backoffMs(attempts + 1)), attempts + 1);
|
|
261
|
+
}
|
|
262
|
+
};
|
|
263
|
+
const handle = async (request) => {
|
|
264
|
+
if (new URL(request.url).pathname !== messagesPath)
|
|
94
265
|
return refuse(404, 'not_found', 'No such path.');
|
|
95
|
-
const
|
|
96
|
-
const provider = Object.hasOwn(options.providers, providerId) ? options.providers[providerId] : undefined;
|
|
97
|
-
const token = tokenOf(request, keyHeaderOf(providerId, provider));
|
|
266
|
+
const token = bearerOf(request);
|
|
98
267
|
if (token === undefined)
|
|
99
268
|
return refuse(401, 'unauthorized', 'The request carries no directory token.');
|
|
100
|
-
let
|
|
269
|
+
let sub;
|
|
101
270
|
try {
|
|
102
|
-
verified = await verifyCallToken({ token, jwks, issuer: options.issuer, audience: options.baseURL });
|
|
271
|
+
const verified = await verifyCallToken({ token, jwks, issuer: options.issuer, audience: options.baseURL });
|
|
272
|
+
if (!(verified.scope ?? '').split(' ').includes(INFERENCE_SCOPE) || !verified.chain.some((link) => link.sub === options.app))
|
|
273
|
+
throw new Error('scope');
|
|
274
|
+
sub = verified.sub;
|
|
103
275
|
}
|
|
104
276
|
catch {
|
|
105
277
|
return refuse(401, 'unauthorized', 'The token is not a valid directory token for this endpoint.');
|
|
106
278
|
}
|
|
107
|
-
if (
|
|
108
|
-
return refuse(
|
|
109
|
-
|
|
110
|
-
if (provider === undefined)
|
|
111
|
-
return refuse(404, 'not_found', `This endpoint serves no provider ${providerId}.`);
|
|
112
|
-
const body = request.method === 'GET' || request.method === 'HEAD' ? undefined : await request.arrayBuffer();
|
|
279
|
+
if (request.method !== 'POST')
|
|
280
|
+
return refuse(405, 'invalid_request_error', 'Only POST is served.');
|
|
281
|
+
const body = await request.arrayBuffer();
|
|
113
282
|
let model;
|
|
283
|
+
let maxTokens = 0;
|
|
284
|
+
let billedFeature = false;
|
|
114
285
|
try {
|
|
115
|
-
|
|
286
|
+
const parsed = object(JSON.parse(new TextDecoder().decode(body)));
|
|
287
|
+
model = parsed?.model;
|
|
288
|
+
if (hasBilledFeature(parsed))
|
|
289
|
+
billedFeature = true;
|
|
290
|
+
if (typeof parsed?.max_tokens === 'number' && parsed.max_tokens > 0)
|
|
291
|
+
maxTokens = parsed.max_tokens;
|
|
116
292
|
}
|
|
117
293
|
catch {
|
|
118
294
|
model = undefined;
|
|
119
295
|
}
|
|
296
|
+
if (billedFeature)
|
|
297
|
+
return refuse(400, 'invalid_request', 'Server-side tools and service tiers are not served here.');
|
|
120
298
|
if (typeof model !== 'string')
|
|
121
299
|
return refuse(400, 'invalid_request', 'The request body must be JSON naming a model.');
|
|
122
|
-
if (!served.has(
|
|
123
|
-
return refuse(404, 'model_unavailable', `This endpoint does not serve ${
|
|
300
|
+
if (!served.has(model))
|
|
301
|
+
return refuse(404, 'model_unavailable', `This endpoint does not serve ${model}.`);
|
|
302
|
+
const admission = await admit(token, model);
|
|
303
|
+
if (admission.kind === 'refused') {
|
|
304
|
+
return Response.json({ type: 'error', error: { type: 'invalid_request_error', message: admission.payment.message }, payment: admission.payment }, { status: 402 });
|
|
305
|
+
}
|
|
306
|
+
if (admission.kind === 'forbidden')
|
|
307
|
+
return refuse(403, 'permission_error', admission.message);
|
|
308
|
+
if (admission.kind === 'unavailable')
|
|
309
|
+
return refuse(503, 'admission_unavailable', 'Northlight could not confirm payment for this call. Try again shortly.');
|
|
124
310
|
const headers = new Headers(request.headers);
|
|
125
311
|
for (const name of STRIPPED_REQUEST_HEADERS)
|
|
126
312
|
headers.delete(name);
|
|
127
|
-
|
|
128
|
-
|
|
313
|
+
const pool = admission.pool;
|
|
314
|
+
if (pool !== undefined)
|
|
315
|
+
headers.set('authorization', `Bearer ${pool.token}`);
|
|
129
316
|
else
|
|
130
|
-
headers.set('
|
|
131
|
-
|
|
317
|
+
headers.set('x-api-key', options.apiKey);
|
|
318
|
+
const upstreamUrl = pool !== undefined ? `${pool.url}/${POOL_MESSAGES_PATH}` : `${VENDOR_URL}/${MESSAGES_PATH}`;
|
|
319
|
+
let answer;
|
|
132
320
|
try {
|
|
133
|
-
|
|
321
|
+
// Not tied to the caller's connection: the vendor bills the whole answer, so the usage branch reads it to its end and the settlement is complete.
|
|
322
|
+
answer = await upstreamFetch(upstreamUrl, { method: 'POST', headers, body, redirect: 'manual' });
|
|
134
323
|
}
|
|
135
324
|
catch {
|
|
136
|
-
return refuse(502, 'upstream_unreachable', `${
|
|
325
|
+
return refuse(502, 'upstream_unreachable', `${pool !== undefined ? 'The Claude pool' : 'anthropic'} could not be reached.`);
|
|
137
326
|
}
|
|
138
|
-
const responseHeaders = new Headers(
|
|
327
|
+
const responseHeaders = new Headers(answer.headers);
|
|
139
328
|
for (const name of STRIPPED_RESPONSE_HEADERS)
|
|
140
329
|
responseHeaders.delete(name);
|
|
141
|
-
if (
|
|
142
|
-
return new Response(
|
|
330
|
+
if (answer.body === null || !answer.ok) {
|
|
331
|
+
return new Response(answer.body, { status: answer.status, statusText: answer.statusText, headers: responseHeaders });
|
|
143
332
|
}
|
|
144
|
-
|
|
145
|
-
const
|
|
146
|
-
|
|
147
|
-
|
|
333
|
+
responseHeaders.set('Crtr-Charge-Id', admission.chargeId);
|
|
334
|
+
const [toCaller, toUsage] = answer.body.tee();
|
|
335
|
+
const vendorRef = answer.headers.get('request-id') ?? `${vendorRefPrefix}:${admission.admissionId}`;
|
|
336
|
+
const contentType = answer.headers.get('content-type') ?? '';
|
|
337
|
+
const calculated = reportOf(toUsage, contentType).then(({ usage, model: servedByVendor, complete }) => {
|
|
338
|
+
if (usage === undefined) {
|
|
339
|
+
log({ sub, model, admission: admission.admissionId, unsettled: 'no_usage' });
|
|
340
|
+
return undefined;
|
|
341
|
+
}
|
|
342
|
+
if (complete && ![
|
|
343
|
+
usage.input_tokens,
|
|
344
|
+
usage.output_tokens,
|
|
345
|
+
usage.cache_read_input_tokens === undefined ? 0 : usage.cache_read_input_tokens,
|
|
346
|
+
usage.cache_creation_input_tokens === undefined ? 0 : usage.cache_creation_input_tokens,
|
|
347
|
+
].every((count) => typeof count === 'number' && Number.isSafeInteger(count) && count >= 0)) {
|
|
348
|
+
log({ sub, model, admission: admission.admissionId, usage, unsettled: 'invalid_usage' });
|
|
349
|
+
return undefined;
|
|
350
|
+
}
|
|
351
|
+
const units = unitsOf(usage);
|
|
352
|
+
// A stream that ended before its final usage event reports only the prompt side: price the output the request allowed instead.
|
|
353
|
+
if (!complete)
|
|
354
|
+
units.output = Math.max(units.output, maxTokens);
|
|
355
|
+
// The vendor may serve another model than the one asked for: the charge is that model's, at its own rates.
|
|
356
|
+
const servedModel = servedByVendor ?? model;
|
|
357
|
+
const priced = pricedModel(servedModel);
|
|
358
|
+
if (priced === undefined) {
|
|
359
|
+
log({ sub, model, served_model: servedModel, admission: admission.admissionId, usage, unsettled: 'unpriced_served_model' });
|
|
360
|
+
return undefined;
|
|
361
|
+
}
|
|
362
|
+
const list = listUsd(priced, units, longCacheWriteOf(usage, units));
|
|
363
|
+
const settlement = {
|
|
364
|
+
admission_id: admission.admissionId,
|
|
365
|
+
served_model: servedModel,
|
|
366
|
+
units,
|
|
367
|
+
list_usd: list,
|
|
368
|
+
cost_usd: pool !== undefined ? 0 : list,
|
|
369
|
+
vendor_ref: vendorRef,
|
|
370
|
+
};
|
|
371
|
+
return { settlement, usage };
|
|
372
|
+
});
|
|
373
|
+
void calculated.then(async (result) => {
|
|
374
|
+
if (result === undefined)
|
|
375
|
+
return;
|
|
376
|
+
const { settlement, usage } = result;
|
|
377
|
+
await record(settlement);
|
|
378
|
+
log({ sub, model, served_model: settlement.served_model, admission: admission.admissionId, upstream: pool !== undefined ? 'pool' : 'api_key', list_usd: settlement.list_usd, cost_usd: settlement.cost_usd, usage });
|
|
379
|
+
}).catch((error) => { log({ sub, admission: admission.admissionId, settle_failed: String(error) }); });
|
|
380
|
+
const charge = calculated.then((result) => result === undefined ? undefined : { id: admission.chargeId, served_model: result.settlement.served_model, list_usd: result.settlement.list_usd }, () => undefined);
|
|
381
|
+
return new Response(chargedBody(toCaller, contentType, charge), { status: answer.status, statusText: answer.statusText, headers: responseHeaders });
|
|
148
382
|
};
|
|
383
|
+
return { handle, sweep };
|
|
149
384
|
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/** Whether the bare model id has a list price. */
|
|
2
|
+
export declare const hasListPrice: (model: string) => boolean;
|
|
3
|
+
/** The priced model a vendor-reported model id is: the id itself, or a dated snapshot of it (`claude-sonnet-5-5-20261001`).
|
|
4
|
+
* Undefined for a model with no list price, which must never be settled at another model's rates. */
|
|
5
|
+
export declare function pricedModel(reported: string): string | undefined;
|
|
6
|
+
export interface Units {
|
|
7
|
+
input: number;
|
|
8
|
+
output: number;
|
|
9
|
+
cache_read: number;
|
|
10
|
+
cache_write: number;
|
|
11
|
+
}
|
|
12
|
+
/** The vendor's own `usage` object (`input_tokens`, `output_tokens`, `cache_read_input_tokens`, `cache_creation_input_tokens`) as settlement units. */
|
|
13
|
+
export declare const unitsOf: (usage: Record<string, unknown>) => Units;
|
|
14
|
+
/** The one-hour cache-write tokens of the vendor's `usage` (`cache_creation.ephemeral_1h_input_tokens`), never more than the call's cache writes. */
|
|
15
|
+
export declare function longCacheWriteOf(usage: Record<string, unknown>, units: Units): number;
|
|
16
|
+
/** The list price in dollars of one call's units. `cacheWrite1h` of the units' cache writes were one-hour writes, which Anthropic
|
|
17
|
+
* bills at twice the base input rate; the rest are five-minute writes. Throws for a model with no entry: the handler serves only priced models. */
|
|
18
|
+
export declare function listUsd(model: string, units: Units, cacheWrite1h?: number): number;
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
const PRICES = {
|
|
2
|
+
'claude-fable-5': { input: 10, output: 50, cacheRead: 1, cacheWrite: 12.5 },
|
|
3
|
+
'claude-fable-5-1': { input: 10, output: 50, cacheRead: 0.25, cacheWrite: 12.5 },
|
|
4
|
+
'claude-opus-5-5': { input: 4, output: 20, cacheRead: 0.2, cacheWrite: 5 },
|
|
5
|
+
'claude-sonnet-5-5': { input: 2, output: 10, cacheRead: 0.2, cacheWrite: 2.5 },
|
|
6
|
+
'claude-haiku-5-5': {
|
|
7
|
+
input: 0.1, output: 0.5, cacheRead: 0.01, cacheWrite: 0.125,
|
|
8
|
+
tier: { above: 100_000, rates: { input: 0.5, output: 2.5, cacheRead: 0.05, cacheWrite: 0.625 } },
|
|
9
|
+
},
|
|
10
|
+
};
|
|
11
|
+
/** Whether the bare model id has a list price. */
|
|
12
|
+
export const hasListPrice = (model) => Object.hasOwn(PRICES, model);
|
|
13
|
+
/** The priced model a vendor-reported model id is: the id itself, or a dated snapshot of it (`claude-sonnet-5-5-20261001`).
|
|
14
|
+
* Undefined for a model with no list price, which must never be settled at another model's rates. */
|
|
15
|
+
export function pricedModel(reported) {
|
|
16
|
+
if (hasListPrice(reported))
|
|
17
|
+
return reported;
|
|
18
|
+
const undated = reported.replace(/-\d{8}$/, '');
|
|
19
|
+
return undated !== reported && hasListPrice(undated) ? undated : undefined;
|
|
20
|
+
}
|
|
21
|
+
const tokens = (usage, key) => {
|
|
22
|
+
const value = usage[key];
|
|
23
|
+
return typeof value === 'number' && Number.isFinite(value) && value > 0 ? value : 0;
|
|
24
|
+
};
|
|
25
|
+
/** The vendor's own `usage` object (`input_tokens`, `output_tokens`, `cache_read_input_tokens`, `cache_creation_input_tokens`) as settlement units. */
|
|
26
|
+
export const unitsOf = (usage) => ({
|
|
27
|
+
input: tokens(usage, 'input_tokens'),
|
|
28
|
+
output: tokens(usage, 'output_tokens'),
|
|
29
|
+
cache_read: tokens(usage, 'cache_read_input_tokens'),
|
|
30
|
+
cache_write: tokens(usage, 'cache_creation_input_tokens'),
|
|
31
|
+
});
|
|
32
|
+
/** The one-hour cache-write tokens of the vendor's `usage` (`cache_creation.ephemeral_1h_input_tokens`), never more than the call's cache writes. */
|
|
33
|
+
export function longCacheWriteOf(usage, units) {
|
|
34
|
+
const creation = usage['cache_creation'];
|
|
35
|
+
const nested = typeof creation === 'object' && creation !== null ? creation['ephemeral_1h_input_tokens'] : undefined;
|
|
36
|
+
return typeof nested === 'number' && Number.isFinite(nested) && nested > 0 ? Math.min(nested, units.cache_write) : 0;
|
|
37
|
+
}
|
|
38
|
+
/** The list price in dollars of one call's units. `cacheWrite1h` of the units' cache writes were one-hour writes, which Anthropic
|
|
39
|
+
* bills at twice the base input rate; the rest are five-minute writes. Throws for a model with no entry: the handler serves only priced models. */
|
|
40
|
+
export function listUsd(model, units, cacheWrite1h = 0) {
|
|
41
|
+
const price = PRICES[model];
|
|
42
|
+
if (price === undefined)
|
|
43
|
+
throw new Error(`No list price for ${model}`);
|
|
44
|
+
const rates = price.tier !== undefined && units.input + units.cache_read + units.cache_write > price.tier.above ? price.tier.rates : price;
|
|
45
|
+
const micro = units.input * rates.input + units.output * rates.output + units.cache_read * rates.cacheRead + (units.cache_write - cacheWrite1h) * rates.cacheWrite + cacheWrite1h * rates.input * 2;
|
|
46
|
+
// dollars per million tokens is micro-dollars per token
|
|
47
|
+
return Math.round(micro) / 1_000_000;
|
|
48
|
+
}
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
name: northlight
|
|
3
3
|
description: "Use when building, debugging or shipping an app that signs people in with Northlight, starts runs on their Northlight runtime, or calls Northlight providers (sms, search, integrations, context, device) with @north-light/sdk."
|
|
4
4
|
metadata:
|
|
5
|
-
sdk-version: "0.3.
|
|
5
|
+
sdk-version: "0.3.625"
|
|
6
6
|
---
|
|
7
7
|
|
|
8
8
|
# Building a Northlight app
|
|
@@ -27,7 +27,7 @@ Never text a person first: Comms refuses a send to anyone who has not texted you
|
|
|
27
27
|
|
|
28
28
|
## First: check this skill matches the SDK
|
|
29
29
|
|
|
30
|
-
This skill is for SDK version `0.3.
|
|
30
|
+
This skill is for SDK version `0.3.625`. If the project has `node_modules/@north-light/sdk/package.json`, compare its `version` with that number. If they differ, run `npx @north-light/sdk skill` in the project, then read the rewritten `.agents/skills/northlight/SKILL.md` (or `.claude/skills/northlight/SKILL.md`) instead of this copy. It needs no account. A project that has no SDK yet starts with `npx @north-light/sdk@latest init <dir>`.
|
|
31
31
|
|
|
32
32
|
## Where things run
|
|
33
33
|
|
package/dist/version.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/** This SDK's release version. Informational only: compatibility is the contract level, not this number. */
|
|
2
|
-
export declare const SDK_VERSION = "0.3.
|
|
2
|
+
export declare const SDK_VERSION = "0.3.625";
|
|
3
3
|
/** The release sent in `Crouter-Minimum-Runtime-Version` on every request. It is frozen, so a runtime from before the
|
|
4
4
|
* contract level existed (which only knows this header) refuses this SDK, while every later runtime accepts it. */
|
|
5
5
|
export declare const LEGACY_FLOOR = "0.3.423";
|
package/dist/version.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// SDK_VERSION is written by scripts/release-version.mjs on every release; do not edit by hand.
|
|
2
2
|
/** This SDK's release version. Informational only: compatibility is the contract level, not this number. */
|
|
3
|
-
export const SDK_VERSION = '0.3.
|
|
3
|
+
export const SDK_VERSION = '0.3.625';
|
|
4
4
|
// LEGACY_FLOOR is written once by `scripts/contract.mjs release`, at the first release that carries the contract level
|
|
5
5
|
// (the `0.0.0` placeholder is replaced there and never again); do not edit by hand.
|
|
6
6
|
/** The release sent in `Crouter-Minimum-Runtime-Version` on every request. It is frozen, so a runtime from before the
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@north-light/sdk",
|
|
3
|
-
"version": "0.3.
|
|
4
|
-
"crouterContract": "1.
|
|
3
|
+
"version": "0.3.625",
|
|
4
|
+
"crouterContract": "1.50",
|
|
5
5
|
"description": "Typed Node and browser client for running crouter agents through the crtrd /v1 API.",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"main": "./dist/index.js",
|
|
@@ -44,9 +44,9 @@
|
|
|
44
44
|
"prepublishOnly": "node -e \"if (process.env.npm_config_tag !== 'next') { console.error('@north-light/sdk publishes only with --tag next; scripts/promote-sdk.mjs is the only mover of latest.'); process.exit(1); }\""
|
|
45
45
|
},
|
|
46
46
|
"dependencies": {
|
|
47
|
-
"@north-light/api": "0.3.
|
|
48
|
-
"@north-light/identity": "0.3.
|
|
49
|
-
"@north-light/plugin": "0.3.
|
|
47
|
+
"@north-light/api": "0.3.625",
|
|
48
|
+
"@north-light/identity": "0.3.625",
|
|
49
|
+
"@north-light/plugin": "0.3.625",
|
|
50
50
|
"jose": "^6.2.1"
|
|
51
51
|
},
|
|
52
52
|
"peerDependencies": {
|