@north-light/sdk 0.3.499 → 0.3.501
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
CHANGED
|
@@ -28,6 +28,8 @@ export { parsePrivateJwk, privateKeyFromFile } from './oauth/keygen.js';
|
|
|
28
28
|
export type { AuthorizeNonce, Connection, ConnectionStore, Identity, OAuth2Options } from './oauth/index.js';
|
|
29
29
|
export type { IdTokenClaims } from '@north-light/identity';
|
|
30
30
|
export { cursorFromRequest, forwardRunEvents } from './resources/forward.js';
|
|
31
|
+
export { INFERENCE_SCOPE, inferenceEndpoint } from './resources/inference-endpoint.js';
|
|
32
|
+
export type { InferenceEndpointOptions, InferenceProvider, InferenceRung, InferenceUsage } from './resources/inference-endpoint.js';
|
|
31
33
|
export { RUN_NOTICE_SCOPE, RunNoticeError, verifyRunNotice } from './resources/run-notice.js';
|
|
32
34
|
export type { RunNotice, VerifyRunNoticeOptions } from './resources/run-notice.js';
|
|
33
35
|
export type { NodeStreamEvent, NodeStreamEventListener, NodeStreamEventType } from './resources/node-stream.js';
|
package/dist/index.js
CHANGED
|
@@ -10,6 +10,7 @@ export { GrantWatcher } from './resources/grant-watcher.js';
|
|
|
10
10
|
export { generateKeyPair } from './oauth/keygen.js';
|
|
11
11
|
export { parsePrivateJwk, privateKeyFromFile } from './oauth/keygen.js';
|
|
12
12
|
export { cursorFromRequest, forwardRunEvents } from './resources/forward.js';
|
|
13
|
+
export { INFERENCE_SCOPE, inferenceEndpoint } from './resources/inference-endpoint.js';
|
|
13
14
|
export { RUN_NOTICE_SCOPE, RunNoticeError, verifyRunNotice } from './resources/run-notice.js';
|
|
14
15
|
export { describeToolDefault, followActivity } from './resources/activity.js';
|
|
15
16
|
export { APIError, APIConnectionError, APIConnectionTimeoutError, APIUserAbortError, AuthenticationError, BadRequestError, ConflictError, CrouterError, DeadlineExceededError, InternalServerError, NoHostedRuntimeError, NotGrantedError, NotFoundError, PermissionDeniedError, RateLimitError, RuntimeVersionError, UnprocessableEntityError, mapError, } from './errors.js';
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/** The scope an inference token carries. */
|
|
2
|
+
export declare const INFERENCE_SCOPE = "crtr:llm";
|
|
3
|
+
export type InferenceRung = 'ultra' | 'strong' | 'medium' | 'light';
|
|
4
|
+
export interface InferenceProvider {
|
|
5
|
+
/** The vendor's own API base URL, for example `https://api.anthropic.com`. */
|
|
6
|
+
baseURL: string;
|
|
7
|
+
/** The app's key for that vendor. It replaces the token on the way upstream and is never logged or returned. */
|
|
8
|
+
apiKey: string;
|
|
9
|
+
/** The header the vendor's API carries its key in: `x-api-key` or `Authorization: Bearer`. Defaults to `x-api-key` for the provider id `anthropic` and to `bearer` for every other id. */
|
|
10
|
+
keyHeader?: 'x-api-key' | 'bearer';
|
|
11
|
+
}
|
|
12
|
+
export interface InferenceUsage {
|
|
13
|
+
/** The person the call ran for, `user:<id>` (the token's `sub`). */
|
|
14
|
+
sub: string;
|
|
15
|
+
/** The provider id, the first path segment after the mount. */
|
|
16
|
+
provider: string;
|
|
17
|
+
/** The model the request named. */
|
|
18
|
+
model: string;
|
|
19
|
+
/** The vendor's status code (always 2xx; a refused or failed request does not report). */
|
|
20
|
+
status: number;
|
|
21
|
+
/** The vendor's own `usage` object, merged across stream events; `undefined` when the answer carried none. */
|
|
22
|
+
usage: Record<string, unknown> | undefined;
|
|
23
|
+
}
|
|
24
|
+
export interface InferenceEndpointOptions {
|
|
25
|
+
/** The directory issuer. */
|
|
26
|
+
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 the handler is mounted. */
|
|
28
|
+
baseURL: string;
|
|
29
|
+
/** The app's own principal, `app:<id>`; the token's grantee must be it. */
|
|
30
|
+
app: string;
|
|
31
|
+
/** Upstream base URL and key per provider id (a model catalog provider id such as `anthropic`). */
|
|
32
|
+
providers: Record<string, InferenceProvider>;
|
|
33
|
+
/** Strength to model: the catalog model ids (`<provider id>/<model>`) the endpoint serves. A request naming any other model is refused. */
|
|
34
|
+
rungs: Partial<Record<InferenceRung, string>>;
|
|
35
|
+
/** Called once per completed request, after its answer has been streamed to the caller. A throw is written to `console.error`. */
|
|
36
|
+
onUsage?: (usage: InferenceUsage) => void | Promise<void>;
|
|
37
|
+
/** The `fetch` used for the upstream call and the directory's keys (default: the global `fetch`). */
|
|
38
|
+
fetch?: typeof fetch;
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* The server side of an app-paid app: a Fetch handler that serves a person's runtime the vendors' own APIs with the
|
|
42
|
+
* app's keys. Mount it at the path of `baseURL`. A request to `<baseURL>/<provider id>/<vendor path>` carries a
|
|
43
|
+
* directory token in the header that vendor's API carries its key in; the handler verifies it (issuer, `aud` =
|
|
44
|
+
* `baseURL`, expiry, scope `crtr:llm`, the app itself as grantee) and answers 401, sending nothing upstream, when it
|
|
45
|
+
* does not hold. A provider id the app did not configure is 404. A request whose JSON body names a model outside
|
|
46
|
+
* `rungs` is refused. Otherwise the request goes upstream unchanged with the app's key in place of the token, and the
|
|
47
|
+
* vendor's answer comes back unchanged, status codes and `retry-after` included. The handler keeps no state, counts
|
|
48
|
+
* no spend and applies no limit.
|
|
49
|
+
*/
|
|
50
|
+
export declare function inferenceEndpoint(options: InferenceEndpointOptions): (request: Request) => Promise<Response>;
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import { JwksCache, verifyCallToken } from '@north-light/identity';
|
|
2
|
+
/** The scope an inference token carries. */
|
|
3
|
+
export const INFERENCE_SCOPE = 'crtr:llm';
|
|
4
|
+
const jwksUrl = (issuer) => `${issuer.replace(/\/+$/, '')}/.well-known/jwks.json`;
|
|
5
|
+
const STRIPPED_REQUEST_HEADERS = ['host', 'connection', 'keep-alive', 'transfer-encoding', 'te', 'upgrade', 'content-length', 'authorization', 'x-api-key', 'cookie', 'proxy-authorization'];
|
|
6
|
+
// The body is decoded and re-streamed, so these describe the upstream wire form, not what we send.
|
|
7
|
+
const STRIPPED_RESPONSE_HEADERS = ['connection', 'keep-alive', 'transfer-encoding', 'content-encoding', 'content-length'];
|
|
8
|
+
function refuse(status, code, message) {
|
|
9
|
+
return new Response(JSON.stringify({ type: 'error', error: { type: code, code, message } }), { status, headers: { 'content-type': 'application/json' } });
|
|
10
|
+
}
|
|
11
|
+
function keyHeaderOf(id, provider) {
|
|
12
|
+
return provider?.keyHeader ?? (id === 'anthropic' ? 'x-api-key' : 'bearer');
|
|
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;
|
|
22
|
+
const reader = body.getReader();
|
|
23
|
+
const decoder = new TextDecoder();
|
|
24
|
+
if (contentType.includes('text/event-stream')) {
|
|
25
|
+
let merged;
|
|
26
|
+
let pending = '';
|
|
27
|
+
const take = (line) => {
|
|
28
|
+
if (!line.startsWith('data:'))
|
|
29
|
+
return;
|
|
30
|
+
let event;
|
|
31
|
+
try {
|
|
32
|
+
event = JSON.parse(line.slice(5).trim());
|
|
33
|
+
}
|
|
34
|
+
catch {
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
const found = object(object(event)?.usage) ?? object(object(object(event)?.message)?.usage);
|
|
38
|
+
if (found !== undefined)
|
|
39
|
+
merged = { ...merged, ...found };
|
|
40
|
+
};
|
|
41
|
+
for (;;) {
|
|
42
|
+
const { done, value } = await reader.read();
|
|
43
|
+
if (done)
|
|
44
|
+
break;
|
|
45
|
+
pending += decoder.decode(value, { stream: true });
|
|
46
|
+
const lines = pending.split('\n');
|
|
47
|
+
pending = lines.pop();
|
|
48
|
+
lines.forEach(take);
|
|
49
|
+
}
|
|
50
|
+
take(pending);
|
|
51
|
+
return merged;
|
|
52
|
+
}
|
|
53
|
+
let text = '';
|
|
54
|
+
for (;;) {
|
|
55
|
+
const { done, value } = await reader.read();
|
|
56
|
+
if (done)
|
|
57
|
+
break;
|
|
58
|
+
text += decoder.decode(value, { stream: true });
|
|
59
|
+
}
|
|
60
|
+
if (!contentType.includes('json'))
|
|
61
|
+
return undefined;
|
|
62
|
+
try {
|
|
63
|
+
return object(object(JSON.parse(text))?.usage);
|
|
64
|
+
}
|
|
65
|
+
catch {
|
|
66
|
+
return undefined;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* The server side of an app-paid app: a Fetch handler that serves a person's runtime the vendors' own APIs with the
|
|
71
|
+
* app's keys. Mount it at the path of `baseURL`. A request to `<baseURL>/<provider id>/<vendor path>` carries a
|
|
72
|
+
* directory token in the header that vendor's API carries its key in; the handler verifies it (issuer, `aud` =
|
|
73
|
+
* `baseURL`, expiry, scope `crtr:llm`, the app itself as grantee) and answers 401, sending nothing upstream, when it
|
|
74
|
+
* does not hold. A provider id the app did not configure is 404. A request whose JSON body names a model outside
|
|
75
|
+
* `rungs` is refused. Otherwise the request goes upstream unchanged with the app's key in place of the token, and the
|
|
76
|
+
* vendor's answer comes back unchanged, status codes and `retry-after` included. The handler keeps no state, counts
|
|
77
|
+
* no spend and applies no limit.
|
|
78
|
+
*/
|
|
79
|
+
export function inferenceEndpoint(options) {
|
|
80
|
+
const upstreamFetch = options.fetch ?? ((input, init) => fetch(input, init));
|
|
81
|
+
const mount = new URL(options.baseURL).pathname.replace(/\/+$/, '');
|
|
82
|
+
const served = new Set(Object.values(options.rungs));
|
|
83
|
+
if (served.size === 0)
|
|
84
|
+
throw new Error('inferenceEndpoint: rungs must name at least one model');
|
|
85
|
+
for (const model of served) {
|
|
86
|
+
const provider = model.split('/')[0];
|
|
87
|
+
if (!model.includes('/') || options.providers[provider] === undefined)
|
|
88
|
+
throw new Error(`inferenceEndpoint: rung model ${model} must be <provider id>/<model> for a configured provider`);
|
|
89
|
+
}
|
|
90
|
+
const jwks = new JwksCache(options.issuer, (input, init) => upstreamFetch(input, init), jwksUrl(options.issuer));
|
|
91
|
+
return async (request) => {
|
|
92
|
+
const url = new URL(request.url);
|
|
93
|
+
if (!url.pathname.startsWith(`${mount}/`))
|
|
94
|
+
return refuse(404, 'not_found', 'No such path.');
|
|
95
|
+
const [providerId, ...restSegments] = url.pathname.slice(mount.length + 1).split('/');
|
|
96
|
+
const provider = Object.hasOwn(options.providers, providerId) ? options.providers[providerId] : undefined;
|
|
97
|
+
const token = tokenOf(request, keyHeaderOf(providerId, provider));
|
|
98
|
+
if (token === undefined)
|
|
99
|
+
return refuse(401, 'unauthorized', 'The request carries no directory token.');
|
|
100
|
+
let verified;
|
|
101
|
+
try {
|
|
102
|
+
verified = await verifyCallToken({ token, jwks, issuer: options.issuer, audience: options.baseURL });
|
|
103
|
+
}
|
|
104
|
+
catch {
|
|
105
|
+
return refuse(401, 'unauthorized', 'The token is not a valid directory token for this endpoint.');
|
|
106
|
+
}
|
|
107
|
+
if (!(verified.scope ?? '').split(' ').includes(INFERENCE_SCOPE) || !verified.chain.some((link) => link.sub === options.app)) {
|
|
108
|
+
return refuse(401, 'unauthorized', 'The token was not minted for this app to serve inference.');
|
|
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();
|
|
113
|
+
let model;
|
|
114
|
+
try {
|
|
115
|
+
model = body === undefined ? undefined : JSON.parse(new TextDecoder().decode(body)).model;
|
|
116
|
+
}
|
|
117
|
+
catch {
|
|
118
|
+
model = undefined;
|
|
119
|
+
}
|
|
120
|
+
if (typeof model !== 'string')
|
|
121
|
+
return refuse(400, 'invalid_request', 'The request body must be JSON naming a model.');
|
|
122
|
+
if (!served.has(`${providerId}/${model}`))
|
|
123
|
+
return refuse(404, 'model_unavailable', `This endpoint does not serve ${providerId}/${model}.`);
|
|
124
|
+
const headers = new Headers(request.headers);
|
|
125
|
+
for (const name of STRIPPED_REQUEST_HEADERS)
|
|
126
|
+
headers.delete(name);
|
|
127
|
+
if (keyHeaderOf(providerId, provider) === 'x-api-key')
|
|
128
|
+
headers.set('x-api-key', provider.apiKey);
|
|
129
|
+
else
|
|
130
|
+
headers.set('authorization', `Bearer ${provider.apiKey}`);
|
|
131
|
+
let upstream;
|
|
132
|
+
try {
|
|
133
|
+
upstream = await upstreamFetch(`${provider.baseURL.replace(/\/+$/, '')}/${restSegments.join('/')}${url.search}`, { method: request.method, headers, body, redirect: 'manual', signal: request.signal });
|
|
134
|
+
}
|
|
135
|
+
catch {
|
|
136
|
+
return refuse(502, 'upstream_unreachable', `${providerId} could not be reached.`);
|
|
137
|
+
}
|
|
138
|
+
const responseHeaders = new Headers(upstream.headers);
|
|
139
|
+
for (const name of STRIPPED_RESPONSE_HEADERS)
|
|
140
|
+
responseHeaders.delete(name);
|
|
141
|
+
if (upstream.body === null || options.onUsage === undefined || !upstream.ok) {
|
|
142
|
+
return new Response(upstream.body, { status: upstream.status, statusText: upstream.statusText, headers: responseHeaders });
|
|
143
|
+
}
|
|
144
|
+
const [toCaller, toUsage] = upstream.body.tee();
|
|
145
|
+
const report = options.onUsage;
|
|
146
|
+
void usageOf(toUsage, upstream.headers.get('content-type') ?? '').then((usage) => report({ sub: verified.sub, provider: providerId, model, status: upstream.status, usage }), () => undefined).catch((error) => { console.error('inferenceEndpoint onUsage failed:', error); });
|
|
147
|
+
return new Response(toCaller, { status: upstream.status, statusText: upstream.statusText, headers: responseHeaders });
|
|
148
|
+
};
|
|
149
|
+
}
|
|
@@ -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.501"
|
|
6
6
|
---
|
|
7
7
|
|
|
8
8
|
# Building a Northlight app
|
|
@@ -11,7 +11,7 @@ An app on Northlight signs people in with their Northlight account, then starts
|
|
|
11
11
|
|
|
12
12
|
## First: check this skill matches the SDK
|
|
13
13
|
|
|
14
|
-
This skill is for SDK version `0.3.
|
|
14
|
+
This skill is for SDK version `0.3.501`. 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. That command needs no account and no crtr. A project that has no SDK yet starts with `npx @north-light/sdk@latest init <dir>`.
|
|
15
15
|
|
|
16
16
|
## Where things run
|
|
17
17
|
|
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.501";
|
|
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.501';
|
|
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.501",
|
|
4
|
+
"crouterContract": "1.16",
|
|
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,8 +44,8 @@
|
|
|
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.
|
|
47
|
+
"@north-light/api": "0.3.501",
|
|
48
|
+
"@north-light/identity": "0.3.501",
|
|
49
49
|
"jose": "^6.2.1"
|
|
50
50
|
},
|
|
51
51
|
"peerDependencies": {
|