@sema-agent/core
Version:
Stateless, task-oriented AI agent core
286 lines (285 loc) • 19.5 kB
TypeScript
/**
* key↔URL route pairing adjudication — the SINGLE source of the pairing law.
*
* The invariant: **the credential a request carries must belong to the URL the request targets.**
* A request's URL half is per-ENTRY (`Model.baseUrl`, a required field) while the credential
* fallback is per-BRAIN-INSTANCE (`options.apiKey ?? config.apiKey`): unadjudicated, a deployment
* credential follows EVERY entry's URL through one brain — third-party hosts included. This module
* is what the three first-party
* brains now consult before shaping a request, what the brain decorators re-walk per hop (each
* hop/route re-resolves the three credential states for ITS model against ITS config), and what
* external consumers (a server annotating a model roster, a conformance suite) import so there is
* exactly one implementation of the law.
*
* The judgment, by credential source (see {@link RouteCredentialSource}):
* - `per-model` → OK always. A per-model credential was resolved FOR this entry (auth hook or an
* auth header on `Model.headers`); it is paired by construction, so the per-model lane pays no
* new cost.
* - `deployment-config` → OK only where the pairing is verifiable-and-true: the deployment DECLARED
* its credential's URL half (`config.baseUrl`) and the request targets exactly that root.
* A declared root with a diverging entry URL is the torn pair this module exists to stop
* (`route.credential_mismatch`). An UNDECLARED root (`config.apiKey` with no `config.baseUrl`)
* stays allowed as the `"unpinned"` posture — that is the documented single-endpoint quick-start
* shape (key on config, URL on the model) and refusing it would break every deployment built from
* it; the posture is surfaced so read faces can annotate the unverifiable pairing instead.
* - `absent` → OK for keyless deployments (nothing to protect); refused as
* `route.credential_missing` only where an entry declares its OWN URL away from a DECLARED
* deployment root — a distinct endpoint with no credential route is a configuration hole, and a
* loud refusal with a fix hint beats an unauthenticated request to a third-party host.
*
* Fail-closed direction: "no credential leak" outranks "silently works", and every refusal names the
* entry, both URL halves, and a one-line fix.
*
* Bad-value loudness (garbage / contradiction / absence / misplacement):
* - garbage — non-string URL/credential inputs throw a `TypeError` here (a programmer error must
* not be laundered into a polite verdict); whitespace-only URLs normalize to "" (undeclared).
* - contradiction — the torn pair refuses with `route.credential_mismatch` + both halves named.
* - absence — `route.credential_missing` with the fix hint, exactly where absence is a hole.
* - misplacement — a credential in the URL, or a non-http(s) root: {@link assertServableRequestRoot}.
*/
import type { Model } from "../internal/llm.js";
import type { RouteAdjudication, RouteCredential, RoutePairingConfig, RoutePairingPosture } from "../internal/llm.js";
import type { Brain, EngineNotice } from "../core/types.js";
/** A base URL's ROUTE KEY — the value the pairing law COMPARES, folded to WIRE equivalence: the
* halves the wire itself treats as case-insensitive (scheme and host, RFC 9110) fold, the scheme's
* default port elides, and PATH and QUERY ride byte-preserved apart from the trailing slashes the
* request composition itself folds (a query selects the endpoint as surely as a path does; folding
* anything the wire keeps would compare two different endpoints equal — the widening this module
* exists to stop). An unparseable value keeps its raw trimmed spelling: fail-closed raw
* comparison. Non-string input throws — see the module note on garbage.
*
* **Never render this.** Byte-preserving on the query means `?api_key=<secret>` rides it verbatim;
* {@link displayUrlOf} is the one spelling allowed into text. */
export declare function routeKeyOf(url: string | undefined): string;
/** A base URL's DISPLAY spelling — the only rendering of a request root allowed into text a human,
* a log or a consumer sees. Composed from the PARSE (never edited out of raw bytes): userinfo
* drops, EVERY query value becomes {@link REDACTED_VALUE}, and a query NAME rides only where it
* really is a name.
* - no secret-name list: a list has a blind spot at the name nobody listed.
* - names ride because dropping the query WHOLE renders two roots differing only in query as one
* string — exactly the pairs the law refuses, so its refusal would read "targets X … paired
* with X". {@link queryDifferenceNote} closes the case where the names match too.
* - a BARE token (`?<token>`) renders as one placeholder with NO name: `URLSearchParams` calls it a
* name with an empty value, and that reading put the payload itself on screen. "Every value is
* withheld" protects only what IS a value, so the raw segment decides what rides.
* - a name rides only where {@link readQuery} finds the query PURE. A query holding any structured
* value — a URL, an e-mail address, anything that is not plain text once decoded — owns the `&`
* and `=` the split relies on, so the bytes left of an `=` are not names and are never spoken:
* such a query renders as ONE placeholder. A deployment's ordinary names (`tenant`, `api-version`,
* `x-request-id`, `filter[name]`, `x.y`) are unaffected, which is what keeps a refusal actionable.
* Answers `""` for an undeclared root, like its key twin. */
export declare function displayUrlOf(url: string | undefined): string;
/**
* The bad-value refusal every base-URL seat owes: **a request root must be an absolute http(s) URL, it
* may not carry the credential itself, and it may not carry a fragment.**
*
* The first two fail the same way if they are not caught here — as a TRANSPORT failure, indistinguishable
* from an outage, retried for the whole backoff ladder. Measured: `file:`/`ftp:` reject with
* `TypeError("fetch failed")` over a causeless "unknown scheme", the exact shell a refused connection uses.
* Stripping userinfo instead of refusing would send an UNAUTHENTICATED request to the host the operator
* thought they had authenticated to. A FRAGMENT fails worse than loudly: `fetch` never sends one, so a root
* carrying it silently targets the root's own path and every endpoint suffix appended after it lands inside
* the discarded part (`https://h/v1#note` + `/chat/completions` → a request to `/v1`). Refused where the
* value ARRIVES — construction for `config.baseUrl`, request build for `Model.baseUrl` — and again where a
* request URL is composed ({@link joinRequestUrl}), so a root that reached a builder by some other path
* cannot compose a wrong URL in silence. An undeclared root is not this rule's question; the PAIRING law
* keeps its tolerance for an unparseable spelling ({@link routeKeyOf}). Answers the value it judged.
*/
export declare function assertServableRequestRoot(url: string | undefined, seat: string): string | undefined;
/**
* The request URL itself: a request ROOT joined with the endpoint SUFFIX its lane speaks. The one
* composition — `${root}${suffix}` put the suffix wherever the root's last character happened to leave it,
* which for a root carrying a query (`https://h/v1?api-version=2024-06-01`, the gateway shape a deployment
* really declares) appended it INSIDE the last query VALUE and sent the request to `/v1`.
*
* The rule, stated once for all four builders: the suffix joins the **path** (exactly one separator,
* whatever trailing slashes the root carries); the **query** rides byte-preserved AFTER it, because a query
* selects the endpoint as surely as a path does and a deployment declares it once for every request; a
* **fragment** never reaches here ({@link assertServableRequestRoot} refuses the root, re-asked here so a
* root that arrived by some other path is refused rather than composed).
*
* Composed from the same parse `fetch` itself would make, so the spelling carries no normalization the wire
* would not have done anyway. A suffix carrying its own `?`/`#` is a programmer error, not an input: the
* query belongs to the root, and the suffix names a path.
*/
export declare function joinRequestUrl(root: string | undefined, suffix: string): string;
/** Remove the userinfo from every authority in free text, WHOLE — the general redactor keeps a short or
* unprefixed username, and `TOKEN@host` / `TOKEN:x-oauth-basic@host` are the canonical PAT spellings. */
export declare function stripUrlCredentials(text: string): string;
/** The request ROOT a brain targets — `Model.baseUrl` else `config.baseUrl` else the lane's default,
* in its canonical spelling ({@link canonicalRootSpelling}: trailing PATH slashes folded, query
* byte-preserved) — with {@link assertServableRequestRoot} applied to both declared halves on the way.
* The three brains each carried a copy of this coalesce-and-strip line, so a rule about base URLs had
* three homes; now it has one. Answers `""` when nothing is declared and the lane has no default —
* whether that is a refusal is the caller's. */
export declare function resolveRequestRoot(modelBaseUrl: string | undefined, configBaseUrl: string | undefined, laneDefault?: string): string;
/** Whether a header bag carries an auth-bearing header (any case). */
export declare function hasAuthCarrier(headers: Record<string, string> | undefined): boolean;
/**
* Resolve WHICH credential a request would carry, tagged with its source — the input half of the
* adjudication. Precedence (most-specific wins):
* 1. `options.apiKey` (per-call, from the per-model auth hook — presence, not truthiness: an
* empty string is a present credential that fails closed at emit),
* 2. an auth carrier in `options.headers` (per-call headers-borne). The bag MUST be per-model
* sourced (the per-model auth hook's headers) — that provenance is what licenses the
* "paired by construction" verdict below. A caller that folds run-wide headers into the
* per-call options must strip auth carriers first (the harness merge does), or a deployment
* credential would ride here and be judged per-model,
* 3. an auth carrier in `model.headers` (the ENTRY's own declared credential),
* 4. the deployment config (`config.apiKey` and/or an auth carrier in `config.headers` —
* deliberately ONE rank, not two: a config carrying BOTH forms is the documented gateway
* dual-auth escape shape, and both ride exactly as they always have; the pairing gate judges
* the pair as one deployment credential),
* 5. absent.
* 1–3 are `per-model`; 4 is `deployment-config`.
*/
export declare function resolveRouteCredential(input: {
optionsApiKey?: string;
optionsHeaders?: Record<string, string>;
modelHeaders?: Record<string, string>;
configApiKey?: string;
configHeaders?: Record<string, string>;
}): RouteCredential;
/**
* The pairing law (module note above). Pure and deterministic: same inputs, same verdict — safe to
* call both at a resolution seat (pre-flight) and again inside a brain's request build.
*/
export declare function adjudicateModelRoute(model: Pick<Model, "id" | "baseUrl">, credential: RouteCredential, config: RoutePairingConfig): RouteAdjudication;
/** Render a refusal as the one-line detail text the brains put in their thrown error (the machine
* code leads so log/transcript consumers can grep it; the fix hint closes it). */
export declare function routeRefusalText(verdict: Extract<RouteAdjudication, {
ok: false;
}>): string;
/**
* Enforce "the resolved credential is the request's ONLY credential" on an already-merged header
* bag — the per-call-auth-replaces rule, generalized to the per-model sources:
* - PER-MODEL apiKey-form winner → every auth carrier is stripped; the brain then emits the key in
* its own wire posture, the single credential on the wire (the pre-existing per-call rule).
* - PER-MODEL headers-borne winner (per-call or entry bag) → carriers are stripped and the winning
* bag's own carriers re-asserted (an entry-declared credential must survive a same- or
* variant-case deployment spelling that the plain merge order would have let win).
* - DEPLOYMENT-CONFIG winner → untouched. The historical bytes stand exactly: a header-only config
* keeps its own spelling, and a config carrying BOTH the apiKey knob and an auth header is the
* documented gateway dual-auth escape shape — the knob's hard-lock write plus the surviving
* header, both on the wire, as they always were.
* - absent → untouched (no carriers exist).
*/
export declare function applyRouteCredentialHeaders(merged: Record<string, string>, credential: RouteCredential, bags: {
model?: Record<string, string>;
options?: Record<string, string>;
}): void;
/**
* The first-party brains' `adjudicateRoute` face, closed over the brain's construction config —
* EXACTLY the resolution + judgment the brain's own request build runs (single source, two call
* sites), so a pre-flight verdict and the request-time verdict can never disagree.
*/
export declare function createBrainRouteJudge(config: {
apiKey?: string;
baseUrl?: string;
headers?: Record<string, string>;
}): (model: Model, perModelAuth?: {
apiKey?: string;
headers?: Record<string, string>;
}) => RouteAdjudication;
/**
* Per-entry pairing status — the read-face predicate a roster/diagnostic surface annotates entries
* with, so a torn pair is visible at configuration time instead of on the first real request.
* `judge` is a brain's optional `adjudicateRoute` face; absent ⇒ `"unknown"` (a custom brain core
* cannot see into — never guessed at).
*/
export type RoutePairingStatus = `ok:${RoutePairingPosture}` | `broken:${"credential_mismatch" | "credential_missing"}` | "unknown";
export declare function routePairingStatus(brain: {
adjudicateRoute?: (model: Model, perModelAuth?: {
apiKey?: string;
headers?: Record<string, string>;
}) => RouteAdjudication | undefined;
}, model: Model, perModelAuth?: {
apiKey?: string;
headers?: Record<string, string>;
}): RoutePairingStatus;
/**
* Pre-flight a DERIVED-leg model (one the engine resolved from a role/tier/system default rather
* than an explicit caller `model`) against the deployment brain's pairing judge. The two-leg
* disposition for a broken pairing:
* - a NAMED model (explicit in the spec) refuses loudly at the brain's request gate — the caller
* asked for that exact route and gets the refusal + fix hint;
* - a DERIVED model must not sink the main-model task it serves — the seat falls back to the
* primary model and announces `route.fallback_to_primary` (the notice IS the loudness; a silent
* swap would hide the misconfiguration this whole surface exists to expose).
* Returns the verdict, or `undefined` when no judgment is possible (the brain has no judge face,
* the judge itself threw, or the auth hook threw or outran the advisory bound — in which case the
* seat proceeds unchanged and the failure/latency surfaces at its own request-time call site
* exactly as before).
*
* The hook call is BOUNDED. The pre-flight is advisory (the request gate re-runs the same law), so
* it must never widen a slow secret manager's blast radius from "the derived call is slow" to "task
* preparation is wedged": past the bound the pre-flight simply abstains. The eventual request still
* resolves the hook itself (the pre-flight result is never reused as a credential — same-inputs
* determinism of the law is what keeps the two calls' verdicts aligned, not a cached secret).
*/
export declare const DERIVED_ROUTE_PREFLIGHT_HOOK_BOUND_MS = 5000;
export declare function adjudicateDerivedRoute(args: {
brain: Brain;
model: Model;
getApiKeyAndHeaders?: ((model: Model) => Promise<{
apiKey?: string;
headers?: Record<string, string>;
} | undefined> | {
apiKey?: string;
headers?: Record<string, string>;
} | undefined) | undefined;
}): Promise<RouteAdjudication | undefined>;
/**
* Whether two resolved models denote the SAME route for pre-flight purposes: identical wire id AND
* identical entry ROUTE KEY. `Model.id` alone is NOT route identity — two catalog entries can
* legitimately share a wire id (the same model served from two endpoints), and a same-id guard
* would silently skip the pre-flight for exactly the split the pairing law exists to judge.
*/
export declare function sameRouteIdentity(a: Pick<Model, "id" | "baseUrl">, b: Pick<Model, "id" | "baseUrl">): boolean;
/** The `route.fallback_to_primary` announcement — one shared shape for every derived-leg seat, so
* a consumer can rely on `detail.{seat, from, to, cause}` regardless of which seat fell back.
*
* `sessionId` (#433, additive): the swap happened while resolving ONE run's seats, so the run is
* what an operator correlates the line against ("which task ran on the fallback model"). The
* AUDIENCE stays `"operator"` — the fix is a credential/catalog fix, and no end user can act on it;
* attribution is a routing key, not an entitlement. Omitted when a caller has no session in hand,
* never fabricated.
*
* `runId` (#499, additive): the SESSION is not the unit this line is about — the fallback happened
* while resolving ONE INVOCATION's seats, and two runs of one session each falling back on the same
* seat used to mint byte-identical notices ("did the same task fall back twice, or two tasks once
* each?" was unanswerable). Same posture as `sessionId`: a correlation key, omitted when the caller
* has none, never fabricated. */
export declare function fallbackToPrimaryNotice(args: {
seat: string;
from: string;
to: string;
verdict: Extract<RouteAdjudication, {
ok: false;
}>;
sessionId?: string;
runId?: string;
}): EngineNotice;
/**
* The TWIN of {@link fallbackToPrimaryNotice}, for a seat whose deployment declared NO fallback
* (`RunnerDeps.autoMode.classifierFallback: "none"`): the pairing is broken, nothing was swapped, and the
* seat's own calls are refused at the request gate — which is what that declaration asked for. Same `detail`
* keys as its twin MINUS `to`, because nothing moved and a `to` naming a model that was not used is the one
* lie a consumer cannot detect; `action` is the discriminator, so the posture is read rather than inferred
* from an absence. `primary` is named in the SENTENCE (what was declined) and not in the detail.
*/
export declare function unroutableSeatNotice(args: {
seat: string;
from: string;
primary: string;
verdict: Extract<RouteAdjudication, {
ok: false;
}>;
sessionId?: string;
runId?: string;
}): EngineNotice;
/** The auth carriers of a header bag, canonicalized (case-fold name → value, sorted) — the
* Model-visible credential fingerprint used by catalog-change disclosure (a same-name entry whose
* URL moved while this fingerprint did not is the "address changed, credential reference did not"
* shape worth announcing). */
export declare function authCarrierFingerprint(headers: Record<string, string> | undefined): string;