UNPKG

@sema-agent/core

Version:

Stateless, task-oriented AI agent core

385 lines (384 loc) • 17 kB
import { AUTH_CARRIER_NAMES } from "./request-params.js"; import { BrainError } from "./errors.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 = "") { const entry = judgeRequestRoot(modelBaseUrl, "Model.baseUrl"); const declared = judgeRequestRoot(configBaseUrl, "config.baseUrl"); const chosen = (entry ?? declared)?.parsed ?? servableParse(laneDefault.trim()); return chosen === undefined ? laneDefault : canonicalRootSpelling(chosen); } export function hasAuthCarrier(headers) { if (!headers) return false; for (const k of Object.keys(headers)) { if (AUTH_CARRIER_NAMES.has(k.toLowerCase())) return true; } return false; } export function resolveRouteCredential(input) { const { optionsApiKey, optionsHeaders, modelHeaders, configApiKey, configHeaders } = input; if (optionsApiKey !== undefined) { if (typeof optionsApiKey !== "string") throw new TypeError(`route pairing: apiKey must be a string (got ${typeof optionsApiKey})`); return { source: "per-model", apiKey: optionsApiKey }; } if (hasAuthCarrier(optionsHeaders)) return { source: "per-model", headersBorne: true, carrierBag: "options" }; if (hasAuthCarrier(modelHeaders)) return { source: "per-model", headersBorne: true, carrierBag: "model" }; 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 = routeKeyOf(model.baseUrl); const declared = routeKeyOf(config.baseUrl); const request = entry || declared; const entryShown = displayUrlOf(entry); const declaredShown = displayUrlOf(declared); const requestShown = displayUrlOf(request); if (credential.source === "per-model") return { ok: true, posture: "per-model" }; if (credential.source === "deployment-config") { if (declared === "") return { ok: true, posture: "unpinned" }; if (request === declared) return { ok: true, posture: "paired" }; return { ok: false, code: "route.credential_mismatch", detail: { modelId: model.id, entryBaseUrl: entryShown, requestBaseUrl: requestShown, configBaseUrl: declaredShown, fixHint: `model "${model.id}" targets ${requestShown} but the deployment credential is paired with ${declaredShown}` + `${queryDifferenceNote(request, declared)}: ` + `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 applyRouteCredentialHeaders(merged, credential, bags) { if (credential.source !== "per-model") return; for (const k of Object.keys(merged)) { if (AUTH_CARRIER_NAMES.has(k.toLowerCase())) delete merged[k]; } if (credential.headersBorne !== true) return; const winner = credential.carrierBag === "options" ? bags.options : bags.model; if (!winner) return; for (const [k, v] of Object.entries(winner)) { if (AUTH_CARRIER_NAMES.has(k.toLowerCase())) merged[k] = v; } } 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, }); return adjudicateModelRoute(model, credential, { baseUrl: config.baseUrl }); }; } 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 verdict.code === "route.credential_mismatch" ? "broken:credential_mismatch" : "broken:credential_missing"; } 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 } : {}), }, }; } export function authCarrierFingerprint(headers) { if (!headers) return ""; const rows = []; for (const [k, v] of Object.entries(headers)) { const fold = k.toLowerCase(); if (AUTH_CARRIER_NAMES.has(fold)) rows.push(`${fold}=${v}`); } return rows.sort().join("\n"); }