@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 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, InferenceProvider, InferenceRung, InferenceUsage } from './resources/inference-endpoint.js';
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
- 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';
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
- 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;
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 the handler is mounted. */
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 grantee must be it. */
37
+ /** The app's own principal, `app:<id>`; the token's actor chain must contain it. */
30
38
  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. */
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
- /** 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`). */
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-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.
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): (request: Request) => Promise<Response>;
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 jwksUrl = (issuer) => `${issuer.replace(/\/+$/, '')}/.well-known/jwks.json`;
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
- function refuse(status, code, message) {
9
- return new Response(JSON.stringify({ type: 'error', error: { type: code, code, message } }), { status, headers: { 'content-type': 'application/json' } });
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
- 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;
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
- 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
- };
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
- const lines = pending.split('\n');
47
- pending = lines.pop();
48
- lines.forEach(take);
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
- 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;
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-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.
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 served = new Set(Object.values(options.rungs));
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
- 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}/`))
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 [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));
266
+ const token = bearerOf(request);
98
267
  if (token === undefined)
99
268
  return refuse(401, 'unauthorized', 'The request carries no directory token.');
100
- let verified;
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 (!(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();
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
- model = body === undefined ? undefined : JSON.parse(new TextDecoder().decode(body)).model;
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(`${providerId}/${model}`))
123
- return refuse(404, 'model_unavailable', `This endpoint does not serve ${providerId}/${model}.`);
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
- if (keyHeaderOf(providerId, provider) === 'x-api-key')
128
- headers.set('x-api-key', provider.apiKey);
313
+ const pool = admission.pool;
314
+ if (pool !== undefined)
315
+ headers.set('authorization', `Bearer ${pool.token}`);
129
316
  else
130
- headers.set('authorization', `Bearer ${provider.apiKey}`);
131
- let upstream;
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
- upstream = await upstreamFetch(`${provider.baseURL.replace(/\/+$/, '')}/${restSegments.join('/')}${url.search}`, { method: request.method, headers, body, redirect: 'manual', signal: request.signal });
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', `${providerId} could not be reached.`);
325
+ return refuse(502, 'upstream_unreachable', `${pool !== undefined ? 'The Claude pool' : 'anthropic'} could not be reached.`);
137
326
  }
138
- const responseHeaders = new Headers(upstream.headers);
327
+ const responseHeaders = new Headers(answer.headers);
139
328
  for (const name of STRIPPED_RESPONSE_HEADERS)
140
329
  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 });
330
+ if (answer.body === null || !answer.ok) {
331
+ return new Response(answer.body, { status: answer.status, statusText: answer.statusText, headers: responseHeaders });
143
332
  }
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 });
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.624"
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.624`. 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>`.
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.624";
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.624';
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.624",
4
- "crouterContract": "1.49",
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.624",
48
- "@north-light/identity": "0.3.624",
49
- "@north-light/plugin": "0.3.624",
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": {