UNPKG

@sema-agent/core

Version:

Stateless, task-oriented AI agent core

534 lines (533 loc) • 25.6 kB
import { createHash } from "node:crypto"; import { BrainError } from "./errors.js"; import { reportCredentialOriginRefusal } from "./status-sink.js"; import { receiptRefusal, receiveOwnProperties } from "../core/own-property-receipt.js"; import { credentialHeaderValues, holdOwnCredentialValues, isCredentialHeaderName, isSchemedCredentialHeader } from "../core/secret-env.js"; const REDACTED_VALUE = "«redacted»"; const REDACTED_CREDENTIALS = "[redacted-credentials]"; function servableParse(raw) { let u; try { u = new URL(raw); } catch { return undefined; } return u.protocol === "http:" || u.protocol === "https:" ? u : undefined; } function composableParse(raw) { let u; try { u = new URL(raw); } catch { return undefined; } return u.host !== "" || u.pathname.startsWith("/") ? u : undefined; } export function routeKeyOf(url) { if (url === undefined) return ""; if (typeof url !== "string") { throw new TypeError(`route pairing: baseUrl must be a string (got ${typeof url})`); } const raw = url.trim(); if (raw === "") return ""; const u = servableParse(raw); if (u === undefined) return raw.replace(/\/+$/, ""); return canonicalRootSpelling(u); } function canonicalRootSpelling(u) { return `${u.protocol}//${u.host}${u.pathname.replace(/\/+$/, "")}${u.search}`; } const QUERY_PURE_TEXT = /^[A-Za-z0-9._~[\]\-+,*!'()$ ]*$/; const QUERY_DECODE_ROUNDS = 3; function decodedToFixedPoint(text) { let cur = text; for (let round = 0; round < QUERY_DECODE_ROUNDS; round++) { if (!cur.includes("%")) return cur; let next; try { next = decodeURIComponent(cur); } catch { return cur; } if (next === cur) return cur; cur = next; } return cur; } function readQuery(search) { if (search === "" || search === "?") return { pure: true, segments: [] }; const segments = search.replace(/^\?/, "").split("&").map((raw) => { const eq = raw.indexOf("="); return eq === -1 ? { raw } : { raw, name: raw.slice(0, eq), value: raw.slice(eq + 1) }; }); const pure = segments.every((sg) => { if (sg.name === undefined) return QUERY_PURE_TEXT.test(decodedToFixedPoint(sg.raw)); const name = decodedToFixedPoint(sg.name); return name !== "" && QUERY_PURE_TEXT.test(name) && QUERY_PURE_TEXT.test(decodedToFixedPoint(sg.value ?? "")); }); return { pure, segments }; } export function displayUrlOf(url) { const key = routeKeyOf(url); if (key === "") return ""; const u = composableParse(key); if (u === undefined) { const cut = key.search(/[?#]/); const head = cut === -1 ? key : key.slice(0, cut); const at = head.lastIndexOf("@"); const shown = at === -1 ? head : `${REDACTED_VALUE}@${head.slice(at + 1)}`; return cut === -1 ? shown : `${shown}${key[cut]}${REDACTED_VALUE}`; } const read = readQuery(u.search); const query = read.segments.length === 0 ? "" : !read.pure ? `?${REDACTED_VALUE}` : `?${read.segments.map((sg) => (sg.raw === "" ? "" : sg.name === undefined ? REDACTED_VALUE : `${sg.name}=${REDACTED_VALUE}`)).join("&")}`; return `${u.protocol}//${u.host}${u.pathname}${query}`.replace(/\/+$/, ""); } function queryDifferenceNote(requestKey, declaredKey) { if (displayUrlOf(requestKey) !== displayUrlOf(declaredKey)) return ""; const names = new Set(); const ua = composableParse(requestKey); const ub = composableParse(declaredKey); if (ua !== undefined && ub !== undefined) { const ra = readQuery(ua.search); const rb = readQuery(ub.search); if (!ra.pure || !rb.pure) { if (ra.segments.length !== rb.segments.length) { return ` (same rendering — the query differs in segment count (${ra.segments.length} vs ${rb.segments.length}); a value here is not plain text, so this line withholds every name)`; } const at = ra.segments.flatMap((sg, i) => (sg.raw === rb.segments[i]?.raw ? [] : [i + 1])); return at.length === 0 ? ` (same rendering — the difference is in a part this line withholds or folds)` : ` (same rendering — the query differs at segment${at.length > 1 ? "s" : ""} ${at.join(", ")} of ${ra.segments.length}; a value here is not plain text, so this line withholds every name)`; } const a = ra.segments; const b = rb.segments; const valuesOf = (segs, n) => segs.filter((sg) => sg.name === n).map((sg) => sg.value).join("\u0000"); for (const n of new Set([...a, ...b].flatMap((sg) => (sg.name === undefined ? [] : [sg.name])))) { if (valuesOf(a, n) !== valuesOf(b, n)) names.add(n); } } return names.size > 0 ? ` (same rendering — the query values differ under: ${[...names].sort().join(", ")})` : ` (same rendering — the difference is in a part this line withholds or folds)`; } export function assertServableRequestRoot(url, seat) { return judgeRequestRoot(url, seat)?.trimmed; } function judgeRequestRoot(url, seat) { if (typeof url !== "string" || url.trim() === "") return undefined; const trimmed = url.trim(); const parsed = URL.canParse(trimmed) ? new URL(trimmed) : undefined; if (parsed === undefined || (parsed.protocol !== "http:" && parsed.protocol !== "https:")) { const named = parsed === undefined ? "it did not parse as a URL" : displayUrlOf(trimmed); throw new BrainError("invalid_request", `${seat} is not a servable request root (${named}) — it must be an ABSOLUTE http(s) URL. Another scheme, ` + `or a spelling that is not a URL at all, fails at the transport in a shape indistinguishable from an ` + `outage, so it would be retried for the whole backoff ladder instead of refused here.`); } if (parsed.username !== "" || parsed.password !== "") { throw new BrainError("invalid_request", `${seat} carries credentials in its authority (${parsed.protocol}//${REDACTED_CREDENTIALS}@${parsed.host}${parsed.pathname}) — ` + `a request cannot be built from a URL with userinfo, so every call through this brain would fail: put the ` + `credential on the apiKey knob or an auth header and leave the URL bare`); } if (parsed.hash !== "") { throw new BrainError("invalid_request", `${seat} carries a URL fragment (${displayUrlOf(trimmed)}#…) — a fragment is never sent on the wire, so the ` + `request would silently target the root's own path and the endpoint suffix would land inside the discarded ` + `part: drop everything from the "#" on. A per-deployment QUERY is kept and rides every request ` + `(a gateway's \`?api-version=…\` is supported); a fragment cannot be.`); } return { trimmed, parsed }; } export function joinRequestUrl(root, suffix) { if (typeof suffix !== "string" || /[?#]/.test(suffix)) { throw new TypeError(`request URL: the endpoint suffix must be a path (got ${typeof suffix === "string" ? JSON.stringify(suffix) : typeof suffix}) — ` + `a request's query belongs to its ROOT, which declares it once for every request`); } const judged = judgeRequestRoot(root, "the request root"); if (judged === undefined) { throw new BrainError("invalid_request", `no request root is declared, so no request URL can be built for "${suffix}" — set config.baseUrl or Model.baseUrl`); } const { parsed } = judged; const tail = suffix.replace(/^\/+/, ""); if (tail === "") return canonicalRootSpelling(parsed); const base = parsed.pathname.replace(/\/+$/, ""); const composed = `${parsed.protocol}//${parsed.host}${base}/${tail}${parsed.search}`; const reread = servableParse(composed); if (reread === undefined || !reread.pathname.startsWith(base === "" ? "/" : `${base}/`)) { throw new TypeError(`request URL: the endpoint suffix ${JSON.stringify(suffix)} does not stay under the request root's path ` + `(${base === "" ? "/" : base}) — a suffix names an endpoint of that root, and one that walks out of it would ` + `send the request, and the credential paired with that root, somewhere the pairing law never judged`); } return composed; } export function stripUrlCredentials(text) { return text.replace(/([A-Za-z][A-Za-z0-9+.\-]*:\/\/)[^/\s"'@]*@/g, `$1${REDACTED_CREDENTIALS}@`); } export function resolveRequestRoot(modelBaseUrl, configBaseUrl, laneDefault = "") { return resolveRequestRootOf(modelBaseUrl, configBaseUrl, laneDefault).root; } export function resolveRequestRootOf(modelBaseUrl, configBaseUrl, laneDefault = "") { const entry = judgeRequestRoot(modelBaseUrl, "Model.baseUrl"); const declared = judgeRequestRoot(configBaseUrl, "config.baseUrl"); const origin = entry !== undefined ? "model" : declared !== undefined ? "deployment" : "lane-default"; const chosen = (entry ?? declared)?.parsed ?? servableParse(laneDefault.trim()); return { root: chosen === undefined ? laneDefault : canonicalRootSpelling(chosen), origin }; } export function hasAuthCarrier(headers) { if (!headers) return false; return Object.keys(headers).some((name) => isCredentialHeaderName(name)); } function routeOriginOf(rootKey) { if (rootKey === "") return ""; const u = servableParse(rootKey); return u === undefined ? rootKey : `${u.protocol}//${u.host}`; } const CREDENTIAL_ORIGIN_BINDINGS_MAX = 16_384; const CREDENTIAL_ORIGIN_TABLE = Symbol.for("sema.core.credentialOriginTable"); function credentialOriginTable() { const held = Reflect.get(globalThis, CREDENTIAL_ORIGIN_TABLE); if (held instanceof Map) return held; const table = new Map(); Reflect.set(globalThis, CREDENTIAL_ORIGIN_TABLE, table); return table; } export function __resetCredentialOriginBindings() { credentialOriginTable().clear(); } const resolvedCredentialFacts = new WeakMap(); const HEADER_VALUE_ENDS = /^[\t\n\r ]+|[\t\n\r ]+$/g; const AUTH_SCHEME_CREDENTIALS = /^[!#$%&'*+.^_`|~0-9A-Za-z-]+[\t ]+(\S[\s\S]*)$/; function perModelCredential(credential, apiKey, bag, conflict) { const headers = {}; for (const [name, value] of Object.entries(bag ?? {})) { if (isCredentialHeaderName(name) && typeof value === "string") headers[name] = value; } const headerValues = Object.entries(headers).map(([name, value]) => { const wire = value.replace(HEADER_VALUE_ENDS, ""); return isSchemedCredentialHeader(name) ? (AUTH_SCHEME_CREDENTIALS.exec(wire)?.[1] ?? wire) : wire; }); const values = apiKey !== undefined ? [apiKey, ...headerValues] : headerValues; const secrets = [...new Set(values.map((v) => v.replace(HEADER_VALUE_ENDS, "")).filter((v) => v !== ""))]; if (secrets.length === 0 && Object.keys(headers).length === 0 && conflict === undefined) return credential; const proof = Object.freeze(credential); resolvedCredentialFacts.set(proof, Object.freeze({ identities: Object.freeze(secrets.map((s) => createHash("sha256").update(s).digest("base64url"))), headers: Object.freeze(headers), ...(conflict !== undefined ? { keyHeaderConflict: Object.freeze(conflict) } : {}), })); return proof; } function boundElsewhere(identities, requestOrigin) { const table = credentialOriginTable(); return identities.map((id) => table.get(id)).find((bound) => bound !== undefined && bound !== requestOrigin); } function bindCredentialOrigin(identities, requestOrigin) { const table = credentialOriginTable(); for (const id of identities) { table.delete(id); if (table.size >= CREDENTIAL_ORIGIN_BINDINGS_MAX) { const oldest = table.keys().next(); if (oldest.done !== true) table.delete(oldest.value); } table.set(id, requestOrigin); } } function routeHalves(model, config) { const entry = routeKeyOf(model.baseUrl); const declared = routeKeyOf(config.baseUrl); const deploymentRoot = declared || routeKeyOf(config.laneDefault); return { entry, declared, deploymentRoot, request: entry || deploymentRoot }; } function keyHeaderConflict(apiKey, headers, keyHeader) { if (apiKey === undefined || apiKey === "" || keyHeader === undefined || headers === undefined) return undefined; const fold = keyHeader.toLowerCase(); const written = Object.keys(headers).find((name) => name.toLowerCase() === fold); return written === undefined ? undefined : { header: written, keyHeader }; } export function resolveRouteCredential(input) { const { optionsApiKey, optionsHeaders, modelHeaders, configApiKey, configHeaders, keyHeader } = input; holdOwnCredentialValues(credentialHeaderValues(modelHeaders)); const answerHeadersBorne = hasAuthCarrier(optionsHeaders); if (optionsApiKey !== undefined || answerHeadersBorne) { if (optionsApiKey !== undefined && typeof optionsApiKey !== "string") throw new TypeError(`route pairing: apiKey must be a string (got ${typeof optionsApiKey})`); return perModelCredential({ source: "per-model", ...(optionsApiKey !== undefined ? { apiKey: optionsApiKey } : {}), ...(answerHeadersBorne ? { headersBorne: true, carrierBag: "options" } : {}), }, optionsApiKey, answerHeadersBorne ? optionsHeaders : undefined, keyHeaderConflict(optionsApiKey, optionsHeaders, keyHeader)); } if (hasAuthCarrier(modelHeaders)) return perModelCredential({ source: "per-model", headersBorne: true, carrierBag: "model" }, undefined, modelHeaders); const configHeadersBorne = hasAuthCarrier(configHeaders); if (configApiKey !== undefined || configHeadersBorne) { if (configApiKey !== undefined && typeof configApiKey !== "string") { throw new TypeError(`route pairing: apiKey must be a string (got ${typeof configApiKey})`); } return { source: "deployment-config", ...(configApiKey !== undefined ? { apiKey: configApiKey } : {}), ...(configHeadersBorne ? { headersBorne: true, carrierBag: "config" } : {}), }; } return { source: "absent" }; } export function adjudicateModelRoute(model, credential, config) { const { entry, declared, deploymentRoot, request } = routeHalves(model, config); const entryShown = displayUrlOf(entry); const declaredShown = displayUrlOf(declared); const requestShown = displayUrlOf(request); if (credential.source === "per-model") { const facts = resolvedCredentialFacts.get(credential); const conflict = facts?.keyHeaderConflict; if (conflict !== undefined) { return { ok: false, code: "route.credential_answer_conflict", detail: { modelId: model.id, entryBaseUrl: entryShown, requestBaseUrl: requestShown, configBaseUrl: declaredShown, fixHint: `model "${model.id}": the request carries both an apiKey and a "${conflict.header}" header, and this lane writes the apiKey in ` + `"${conflict.keyHeader}" — one header cannot hold two credentials; hand the key as apiKey or as the header, not both`, }, }; } const identities = facts?.identities; const requestOrigin = routeOriginOf(request); if (identities === undefined || requestOrigin === "") return { ok: true, posture: "per-model" }; const boundOrigin = boundElsewhere(identities, requestOrigin); if (boundOrigin === undefined) return { ok: true, posture: "per-model" }; const boundShown = displayUrlOf(boundOrigin); const requestOriginShown = displayUrlOf(requestOrigin); return { ok: false, code: "route.credential_origin_mismatch", detail: { modelId: model.id, entryBaseUrl: entryShown, requestBaseUrl: requestShown, configBaseUrl: declaredShown, boundOrigin: boundShown, requestOrigin: requestOriginShown, fixHint: `model "${model.id}" targets ${requestShown} (origin ${requestOriginShown}) but its own credential is bound to ${boundShown}, ` + `the origin it was first sent to — a credential does not follow its entry to another host: change the entry's credential ` + `reference in the same step as its baseUrl (a new credential is a new binding), or point the entry back at ${boundShown}; ` + `a binding lasts as long as the engine process, so a provider that only moved domains and accepts the same credential there needs a restart`, }, }; } if (credential.source === "deployment-config") { if (request !== "" && request === deploymentRoot) return { ok: true, posture: "paired" }; const pairedWith = declared !== "" ? `is paired with ${declaredShown}` : deploymentRoot !== "" ? `is paired with this brain's default endpoint ${displayUrlOf(deploymentRoot)} (config.baseUrl is unset)` : `names no root of its own (config.baseUrl is unset)`; return { ok: false, code: "route.credential_mismatch", detail: { modelId: model.id, entryBaseUrl: entryShown, requestBaseUrl: requestShown, configBaseUrl: declaredShown, fixHint: `model "${model.id}" targets ${requestShown || "no root"} but the deployment credential ${pairedWith}` + `${queryDifferenceNote(request, deploymentRoot)}: ` + `give the model its own credential (a per-model key via getApiKeyAndHeaders, or an auth header on Model.headers), ` + `or serve it through a brain whose config.baseUrl is the model's own root`, }, }; } if (declared === "" || entry === "" || request === declared) return { ok: true, posture: "keyless" }; return { ok: false, code: "route.credential_missing", detail: { modelId: model.id, entryBaseUrl: entryShown, requestBaseUrl: requestShown, configBaseUrl: declaredShown, fixHint: `model "${model.id}" targets ${requestShown} but no credential is configured for that endpoint ` + `(the deployment credential root is ${declaredShown})${queryDifferenceNote(request, declared)}: supply a per-model credential ` + `(getApiKeyAndHeaders, or an auth header on Model.headers), or serve it through a brain constructed for that root`, }, }; } export function routeRefusalText(verdict) { return `${verdict.code}: ${verdict.detail.fixHint}`; } export function routeRefusalError(verdict) { const { modelId, boundOrigin, requestOrigin } = verdict.detail; if (verdict.code === "route.credential_origin_mismatch" && boundOrigin !== undefined && requestOrigin !== undefined) { reportCredentialOriginRefusal({ modelId, boundOrigin, requestOrigin }); } return new BrainError("invalid_request", routeRefusalText(verdict)); } export function admitRouteRequest(model, credential, config) { const judged = { id: model.id, baseUrl: model.baseUrl }; const { baseUrl, laneDefault } = config; const pairing = { ...(baseUrl !== undefined ? { baseUrl } : {}), ...(laneDefault !== undefined ? { laneDefault } : {}) }; const judgeOrRefuse = () => { const verdict = adjudicateModelRoute(judged, credential, pairing); if (!verdict.ok) throw routeRefusalError(verdict); }; judgeOrRefuse(); const identities = credential.source === "per-model" ? resolvedCredentialFacts.get(credential)?.identities : undefined; const requestOrigin = routeOriginOf(routeHalves(judged, pairing).request); if (identities === undefined || identities.length === 0 || requestOrigin === "") return () => { }; return () => { judgeOrRefuse(); bindCredentialOrigin(identities, requestOrigin); }; } export function applyRouteCredentialHeaders(merged, credential) { if (credential.source !== "per-model") return; for (const k of Object.keys(merged)) { if (isCredentialHeaderName(k)) delete merged[k]; } Object.assign(merged, resolvedCredentialFacts.get(credential)?.headers); } export function brainConfigSnapshot(given, subject) { const words = { subject, unit: "member", moment: "at construction" }; const received = receiveOwnProperties(given, words); const headers = received.headers; if (headers !== undefined) { if (headers === null || typeof headers !== "object") throw receiptRefusal(`${subject}.headers must be an object of header names to values`); Object.defineProperty(received, "headers", { value: Object.freeze(receiveOwnProperties(headers, { ...words, subject: `${subject}.headers`, unit: "header" })), enumerable: true }); } const snapshot = Object.freeze(received); holdOwnCredentialValues([snapshot.apiKey, ...credentialHeaderValues(snapshot.headers)]); return snapshot; } export function createBrainRouteJudge(config) { return (model, perModelAuth) => { const credential = resolveRouteCredential({ optionsApiKey: perModelAuth?.apiKey, optionsHeaders: perModelAuth?.headers, modelHeaders: model.headers, configApiKey: config.apiKey, configHeaders: config.headers, keyHeader: config.keyHeader, }); return adjudicateModelRoute(model, credential, { baseUrl: config.baseUrl, laneDefault: config.laneDefault }); }; } const BROKEN_PAIRING_STATUS = { "route.credential_mismatch": "broken:credential_mismatch", "route.credential_missing": "broken:credential_missing", "route.credential_origin_mismatch": "broken:credential_origin_mismatch", "route.credential_answer_conflict": "broken:credential_answer_conflict", }; export function routePairingStatus(brain, model, perModelAuth) { const verdict = brain.adjudicateRoute?.(model, perModelAuth); if (verdict === undefined) return "unknown"; if (verdict.ok) return `ok:${verdict.posture}`; return BROKEN_PAIRING_STATUS[verdict.code]; } export const DERIVED_ROUTE_PREFLIGHT_HOOK_BOUND_MS = 5_000; export async function adjudicateDerivedRoute(args) { const judge = args.brain.adjudicateRoute; if (judge === undefined) return undefined; let auth; if (args.getApiKeyAndHeaders !== undefined) { let timer; const ABSTAIN = Symbol("preflight-abstain"); try { const bound = new Promise((resolve) => { timer = setTimeout(() => resolve(ABSTAIN), DERIVED_ROUTE_PREFLIGHT_HOOK_BOUND_MS); }); const hookResult = Promise.resolve(args.getApiKeyAndHeaders(args.model)); hookResult.catch(() => { }); const raced = await Promise.race([hookResult, bound]); if (raced === ABSTAIN) return undefined; auth = raced; } catch { return undefined; } finally { if (timer !== undefined) clearTimeout(timer); } } try { return judge(args.model, auth); } catch { return undefined; } } export function sameRouteIdentity(a, b) { return a.id === b.id && routeKeyOf(a.baseUrl) === routeKeyOf(b.baseUrl); } export function fallbackToPrimaryNotice(args) { return { code: "route.fallback_to_primary", message: `derived model "${args.from}" (${args.seat}) is not routable (${args.verdict.code}) — ` + `falling back to the primary model "${args.to}". ${args.verdict.detail.fixHint}`, detail: { seat: args.seat, from: args.from, to: args.to, cause: args.verdict.code, fixHint: args.verdict.detail.fixHint, ...(args.sessionId !== undefined ? { sessionId: args.sessionId } : {}), ...(args.runId !== undefined ? { runId: args.runId } : {}), }, }; } export function unroutableSeatNotice(args) { return { code: "route.classifier_unroutable", message: `derived model "${args.from}" (${args.seat}) is not routable (${args.verdict.code}) and this deployment declared no fallback — ` + `the seat is kept and its calls are refused rather than served by "${args.primary}". ${args.verdict.detail.fixHint}`, detail: { seat: args.seat, from: args.from, cause: args.verdict.code, fixHint: args.verdict.detail.fixHint, action: "refused", ...(args.sessionId !== undefined ? { sessionId: args.sessionId } : {}), ...(args.runId !== undefined ? { runId: args.runId } : {}), }, }; }