@north-light/sdk 0.3.632 → 0.3.634

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.
@@ -36,7 +36,7 @@ export interface InferenceEndpointOptions {
36
36
  baseURL: string;
37
37
  /** The app's own principal, `app:<id>`; it prefixes the vendor reference of a call the vendor gave no request id. */
38
38
  app: string;
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. */
39
+ /** The app's Anthropic API key. It serves a call the directory admits as card-paid (no `pool` in the admission) and a Northlight-paid call the pool could not serve (see `inferenceEndpoint`). It is never logged or returned. */
40
40
  apiKey: string;
41
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. */
42
42
  rungs: Partial<Record<InferenceRung, string>>;
@@ -60,9 +60,15 @@ export interface InferenceEndpointOptions {
60
60
  * directory's `payment` body, and a directory that cannot be reached or answers anything unexpected is 503 with nothing sent upstream.
61
61
  *
62
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.
63
+ * `pool.url` with `pool.token`, and a call the pool serves settles at list price with `cost_usd` 0. An answer without it (the person
64
+ * pays) sends the call to Anthropic with the app's `apiKey`, and `cost_usd` equals the list price.
65
+ *
66
+ * When the pool cannot serve a Northlight-paid call, the same request is sent to Anthropic with `apiKey` instead of being refused.
67
+ * That happens when the pool is unreachable or answers 401, 402, 403, 404, 408, 429 or any 5xx (every login rate-limited, no login,
68
+ * a pool-side refusal, a pool failure). Any other pool answer, notably a 400 for a malformed request, goes back to the caller
69
+ * unchanged, because the key would fail the same way. A call the key serves settles under the same admission with `cost_usd` equal
70
+ * to the list price (Northlight paid real money for it), so the directory counts it as it counts a pool call, and the log entry
71
+ * says `upstream: 'api_key_fallback'` with the pool's status. If the key's attempt fails too, its answer (or a 502) reaches the caller.
66
72
  *
67
73
  * The answer streams back with `Crtr-Charge-Id` and calculated `crtr_charge` on its terminal event (or JSON body), without waiting
68
74
  * for settlement. Usage is read off the stream and reported to the directory at the list price of the model the vendor reports it
@@ -9,6 +9,9 @@ const DIRECTORY_TIMEOUT_MS = 10_000;
9
9
  /** Posts made in the request's own lifetime before the stored report is left to `sweep`. */
10
10
  const IMMEDIATE_ATTEMPTS = 3;
11
11
  const SWEEP_BATCH = 50;
12
+ /** Pool answers the API key could plausibly serve instead: the pool or its login is unavailable or refused the call (401, 402, 403, 404 no login, 408), every login is rate-limited (429), or the pool failed (5xx). Any other status, 400 above all, would fail the same way at Anthropic and reaches the caller. */
13
+ const POOL_FALLBACK_STATUSES = new Set([401, 402, 403, 404, 408, 429]);
14
+ const poolFallbackStatus = (status) => POOL_FALLBACK_STATUSES.has(status) || status >= 500;
12
15
  const STRIPPED_REQUEST_HEADERS = ['host', 'connection', 'keep-alive', 'transfer-encoding', 'te', 'upgrade', 'content-length', 'authorization', 'x-api-key', 'cookie', 'proxy-authorization'];
13
16
  // The body is decoded and re-streamed, so these describe the upstream wire form, not what we send.
14
17
  const STRIPPED_RESPONSE_HEADERS = ['connection', 'keep-alive', 'transfer-encoding', 'content-encoding', 'content-length'];
@@ -150,9 +153,15 @@ function chargedBody(body, contentType, charge) {
150
153
  * directory's `payment` body, and a directory that cannot be reached or answers anything unexpected is 503 with nothing sent upstream.
151
154
  *
152
155
  * 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
+ * `pool.url` with `pool.token`, and a call the pool serves settles at list price with `cost_usd` 0. An answer without it (the person
157
+ * pays) sends the call to Anthropic with the app's `apiKey`, and `cost_usd` equals the list price.
158
+ *
159
+ * When the pool cannot serve a Northlight-paid call, the same request is sent to Anthropic with `apiKey` instead of being refused.
160
+ * That happens when the pool is unreachable or answers 401, 402, 403, 404, 408, 429 or any 5xx (every login rate-limited, no login,
161
+ * a pool-side refusal, a pool failure). Any other pool answer, notably a 400 for a malformed request, goes back to the caller
162
+ * unchanged, because the key would fail the same way. A call the key serves settles under the same admission with `cost_usd` equal
163
+ * to the list price (Northlight paid real money for it), so the directory counts it as it counts a pool call, and the log entry
164
+ * says `upstream: 'api_key_fallback'` with the pool's status. If the key's attempt fails too, its answer (or a 502) reaches the caller.
156
165
  *
157
166
  * The answer streams back with `Crtr-Charge-Id` and calculated `crtr_charge` on its terminal event (or JSON body), without waiting
158
167
  * for settlement. Usage is read off the stream and reported to the directory at the list price of the model the vendor reports it
@@ -207,7 +216,7 @@ export function inferenceEndpoint(options) {
207
216
  if (pool === undefined)
208
217
  return { kind: 'admitted', admissionId: admission_id, chargeId: charge_id, pool: undefined };
209
218
  const { url, token: poolToken } = object(pool) ?? {};
210
- // A pool the helper cannot read is not an admission for the API key.
219
+ // An admission whose pool the helper cannot read is malformed, so the call is not served.
211
220
  return typeof url === 'string' && url !== '' && typeof poolToken === 'string' && poolToken !== ''
212
221
  ? { kind: 'admitted', admissionId: admission_id, chargeId: charge_id, pool: { url: url.replace(/\/+$/, ''), token: poolToken } }
213
222
  : { kind: 'unavailable' };
@@ -307,22 +316,47 @@ export function inferenceEndpoint(options) {
307
316
  return refuse(403, 'permission_error', admission.message);
308
317
  if (admission.kind === 'unavailable')
309
318
  return refuse(503, 'admission_unavailable', 'Northlight could not confirm payment for this call. Try again shortly.');
310
- const headers = new Headers(request.headers);
319
+ const baseHeaders = new Headers(request.headers);
311
320
  for (const name of STRIPPED_REQUEST_HEADERS)
312
- headers.delete(name);
313
- const pool = admission.pool;
314
- if (pool !== undefined)
321
+ baseHeaders.delete(name);
322
+ // 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.
323
+ const sendToPool = (pool) => {
324
+ const headers = new Headers(baseHeaders);
315
325
  headers.set('authorization', `Bearer ${pool.token}`);
316
- else
326
+ return upstreamFetch(`${pool.url}/${POOL_MESSAGES_PATH}`, { method: 'POST', headers, body, redirect: 'manual' });
327
+ };
328
+ const sendToVendor = () => {
329
+ const headers = new Headers(baseHeaders);
317
330
  headers.set('x-api-key', options.apiKey);
318
- const upstreamUrl = pool !== undefined ? `${pool.url}/${POOL_MESSAGES_PATH}` : `${VENDOR_URL}/${MESSAGES_PATH}`;
331
+ return upstreamFetch(`${VENDOR_URL}/${MESSAGES_PATH}`, { method: 'POST', headers, body, redirect: 'manual' });
332
+ };
319
333
  let answer;
320
- try {
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' });
334
+ let upstream = admission.pool !== undefined ? 'pool' : 'api_key';
335
+ if (admission.pool !== undefined) {
336
+ let poolFailure;
337
+ try {
338
+ answer = await sendToPool(admission.pool);
339
+ if (poolFallbackStatus(answer.status)) {
340
+ poolFailure = String(answer.status);
341
+ void answer.body?.cancel().catch(() => undefined);
342
+ answer = undefined;
343
+ }
344
+ }
345
+ catch (error) {
346
+ poolFailure = String(error);
347
+ }
348
+ if (poolFailure !== undefined) {
349
+ upstream = 'api_key_fallback';
350
+ log({ sub, model, admission: admission.admissionId, upstream, pool_failed: poolFailure });
351
+ }
323
352
  }
324
- catch {
325
- return refuse(502, 'upstream_unreachable', `${pool !== undefined ? 'The Claude pool' : 'anthropic'} could not be reached.`);
353
+ if (answer === undefined) {
354
+ try {
355
+ answer = await sendToVendor();
356
+ }
357
+ catch {
358
+ return refuse(502, 'upstream_unreachable', `${upstream === 'api_key_fallback' ? 'The Claude pool and anthropic' : 'anthropic'} could not be reached.`);
359
+ }
326
360
  }
327
361
  const responseHeaders = new Headers(answer.headers);
328
362
  for (const name of STRIPPED_RESPONSE_HEADERS)
@@ -365,7 +399,7 @@ export function inferenceEndpoint(options) {
365
399
  served_model: servedModel,
366
400
  units,
367
401
  list_usd: list,
368
- cost_usd: pool !== undefined ? 0 : list,
402
+ cost_usd: upstream === 'pool' ? 0 : list,
369
403
  vendor_ref: vendorRef,
370
404
  };
371
405
  return { settlement, usage };
@@ -375,7 +409,7 @@ export function inferenceEndpoint(options) {
375
409
  return;
376
410
  const { settlement, usage } = result;
377
411
  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 });
412
+ log({ sub, model, served_model: settlement.served_model, admission: admission.admissionId, upstream, list_usd: settlement.list_usd, cost_usd: settlement.cost_usd, usage });
379
413
  }).catch((error) => { log({ sub, admission: admission.admissionId, settle_failed: String(error) }); });
380
414
  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
415
  return new Response(chargedBody(toCaller, contentType, charge), { status: answer.status, statusText: answer.statusText, headers: responseHeaders });
@@ -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.632"
5
+ sdk-version: "0.3.634"
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.632`. 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.634`. 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.632";
2
+ export declare const SDK_VERSION = "0.3.634";
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.632';
3
+ export const SDK_VERSION = '0.3.634';
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,6 +1,6 @@
1
1
  {
2
2
  "name": "@north-light/sdk",
3
- "version": "0.3.632",
3
+ "version": "0.3.634",
4
4
  "crouterContract": "1.51",
5
5
  "description": "Typed Node and browser client for running crouter agents through the crtrd /v1 API.",
6
6
  "type": "module",
@@ -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.632",
48
- "@north-light/identity": "0.3.632",
49
- "@north-light/plugin": "0.3.632",
47
+ "@north-light/api": "0.3.634",
48
+ "@north-light/identity": "0.3.634",
49
+ "@north-light/plugin": "0.3.634",
50
50
  "jose": "^6.2.1"
51
51
  },
52
52
  "peerDependencies": {