@sema-agent/core 5.55.0 → 5.57.0

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.
Files changed (81) hide show
  1. package/CHANGELOG.md +140 -0
  2. package/dist/agents/send-message-tool.d.ts +11 -0
  3. package/dist/agents/send-message-tool.js +81 -13
  4. package/dist/agents/subagent.js +250 -89
  5. package/dist/agents/team.d.ts +10 -1
  6. package/dist/agents/team.js +1 -0
  7. package/dist/brain/anthropic.js +15 -5
  8. package/dist/brain/circuit-breaker.js +2 -1
  9. package/dist/brain/degrading.js +4 -1
  10. package/dist/brain/failover.js +16 -1
  11. package/dist/brain/open-responses.js +15 -5
  12. package/dist/brain/openai.js +16 -5
  13. package/dist/brain/request-params.d.ts +30 -27
  14. package/dist/brain/request-params.js +1 -7
  15. package/dist/brain/route-adjudicator.d.ts +190 -0
  16. package/dist/brain/route-adjudicator.js +189 -0
  17. package/dist/brain/route-conformance.d.ts +55 -0
  18. package/dist/brain/route-conformance.js +136 -0
  19. package/dist/brain/routing.js +8 -3
  20. package/dist/core/auto-compaction.d.ts +17 -4
  21. package/dist/core/auto-compaction.js +3 -0
  22. package/dist/core/context-edit.d.ts +55 -6
  23. package/dist/core/context-edit.js +12 -1
  24. package/dist/core/hooks.d.ts +293 -11
  25. package/dist/core/hooks.js +158 -11
  26. package/dist/core/human-input-projection.d.ts +20 -2
  27. package/dist/core/human-input-projection.js +9 -0
  28. package/dist/core/mcp.js +4 -4
  29. package/dist/core/memory-engine/engine.d.ts +15 -5
  30. package/dist/core/memory-engine/engine.js +3 -1
  31. package/dist/core/permission-rule-consent.d.ts +45 -0
  32. package/dist/core/permission-rule-consent.js +40 -11
  33. package/dist/core/permission-rule-model.d.ts +110 -75
  34. package/dist/core/permission-rule-model.js +61 -28
  35. package/dist/core/permission-rules.d.ts +23 -15
  36. package/dist/core/permission-rules.js +40 -31
  37. package/dist/core/runner/prepare-task.d.ts +8 -0
  38. package/dist/core/runner/prepare-task.js +66 -26
  39. package/dist/core/runner/runtask.d.ts +4 -1
  40. package/dist/core/runner/runtask.js +206 -21
  41. package/dist/core/runner/session-rule-policy.js +5 -5
  42. package/dist/core/scheduler.d.ts +5 -0
  43. package/dist/core/session-reconcile.d.ts +32 -0
  44. package/dist/core/session-reconcile.js +15 -0
  45. package/dist/core/side-query.d.ts +12 -5
  46. package/dist/core/task-notification.d.ts +34 -7
  47. package/dist/core/task-notification.js +11 -1
  48. package/dist/core/task-registry-agent.d.ts +20 -3
  49. package/dist/core/task-registry-agent.js +31 -2
  50. package/dist/core/tool-policy.d.ts +14 -9
  51. package/dist/core/tool-policy.js +27 -22
  52. package/dist/core/types.d.ts +69 -11
  53. package/dist/core/untrusted-text.js +8 -0
  54. package/dist/engine/compaction/compaction.d.ts +77 -7
  55. package/dist/engine/compaction/compaction.js +98 -9
  56. package/dist/engine/compaction/utils.d.ts +4 -0
  57. package/dist/engine/compaction/utils.js +6 -0
  58. package/dist/engine/harness/agent-harness.d.ts +84 -0
  59. package/dist/engine/harness/agent-harness.js +114 -13
  60. package/dist/engine/harness/messages.d.ts +4 -2
  61. package/dist/engine/harness/messages.js +7 -2
  62. package/dist/engine/harness/types.d.ts +16 -6
  63. package/dist/engine/llm/types.d.ts +65 -0
  64. package/dist/engine/loop/types.d.ts +7 -0
  65. package/dist/engine/session/import-validate.js +10 -0
  66. package/dist/engine/session/session.js +2 -2
  67. package/dist/index.d.ts +4 -1
  68. package/dist/index.js +3 -1
  69. package/dist/internal/llm.d.ts +1 -1
  70. package/dist/orchestration/run-spec.js +8 -1
  71. package/dist/prompts/default.d.ts +12 -6
  72. package/dist/prompts/default.js +2 -0
  73. package/dist/scenarios/scenario-registry.d.ts +5 -1
  74. package/dist/scenarios/scenario-registry.js +4 -2
  75. package/dist/tools/fs/index.js +8 -1
  76. package/dist/tools/scheduler-tools.js +28 -6
  77. package/dist/tools/web.d.ts +15 -0
  78. package/dist/tools/web.js +8 -2
  79. package/dist/tools/worktree.js +2 -2
  80. package/package.json +1 -1
  81. package/test/export-surface.snapshot.json +19 -1
@@ -5,7 +5,9 @@ import { createRepetitionPoll, parseStreamedToolArgs } from "./stream-shared.js"
5
5
  import { mintFallbackToolCallId } from "./tool-call-id.js";
6
6
  import { emitBrainTelemetry } from "./status-sink.js";
7
7
  import { errorResultMediaNote, IMAGE_OMITTED_NO_VISION, imagesOmittedNoVisionNote, modelSupportsVision, sendableImages } from "./media-degrade.js";
8
- import { OPENAI_RESERVED, OUTPUT_CAP_KEYS, applyExtraBody, effectiveOutputCap, lockHeader, mergeHeaders, stripAuthHeaders } from "./request-params.js";
8
+ import { OPENAI_RESERVED, OUTPUT_CAP_KEYS, applyExtraBody, effectiveOutputCap, lockHeader, mergeHeaders } from "./request-params.js";
9
+ import { BrainError } from "./errors.js";
10
+ import { adjudicateModelRoute, applyRouteCredentialHeaders, createBrainRouteJudge, resolveRouteCredential, routeRefusalText } from "./route-adjudicator.js";
9
11
  import { mintEffortWireValue, reasoningRequestCarried } from "./reasoning.js";
10
12
  import { runStreamingBrain } from "./stream-engine.js";
11
13
  function closeToolCallAccum(acc) {
@@ -270,7 +272,17 @@ export function createOpenAIBrain(config = {}) {
270
272
  httpLabel: "gateway",
271
273
  stallTimeouts: options?.stallTimeouts,
272
274
  buildRequest: (overrides) => {
273
- const apiKey = options?.apiKey ?? config.apiKey;
275
+ const routeCredential = resolveRouteCredential({
276
+ optionsApiKey: options?.apiKey,
277
+ optionsHeaders: options?.headers,
278
+ modelHeaders: model.headers,
279
+ configApiKey: config.apiKey,
280
+ configHeaders: config.headers,
281
+ });
282
+ const routeVerdict = adjudicateModelRoute(model, routeCredential, { baseUrl: config.baseUrl });
283
+ if (!routeVerdict.ok)
284
+ throw new BrainError("invalid_request", routeRefusalText(routeVerdict));
285
+ const apiKey = routeCredential.apiKey;
274
286
  const root = (model.baseUrl || config.baseUrl || "").replace(/\/+$/, "");
275
287
  if (!root) {
276
288
  throw new Error("createOpenAIBrain: no baseUrl configured (set config.baseUrl or model.baseUrl)");
@@ -304,8 +316,7 @@ export function createOpenAIBrain(config = {}) {
304
316
  body.stop = options.stop;
305
317
  applyThinking(body, model, options?.reasoning);
306
318
  const headers = mergeHeaders(model.headers, config.headers, options?.headers);
307
- if (options?.apiKey !== undefined)
308
- stripAuthHeaders(headers);
319
+ applyRouteCredentialHeaders(headers, routeCredential, { model: model.headers, options: options?.headers });
309
320
  lockHeader(headers, "content-type", "application/json");
310
321
  if (apiKey)
311
322
  headers["authorization"] = `Bearer ${apiKey}`;
@@ -618,5 +629,5 @@ export function createOpenAIBrain(config = {}) {
618
629
  },
619
630
  });
620
631
  };
621
- return { stream };
632
+ return { stream, adjudicateRoute: createBrainRouteJudge(config) };
622
633
  }
@@ -22,17 +22,16 @@ export declare function reservedFor(api: string): ReadonlySet<string>;
22
22
  * deployment with no `extraBody` is unaffected.
23
23
  */
24
24
  export declare function applyExtraBody(body: Record<string, unknown>, extraBody: Record<string, unknown> | undefined, reserved: ReadonlySet<string>): Record<string, unknown>;
25
- /**
26
- * Per-call auth REPLACES construction-time auth: drop every auth-bearing header
27
- * (case-insensitive `authorization` / `x-api-key`) from an already-merged header bag. Called by a
28
- * brain's buildRequest ONLY when a per-call `options.apiKey` is present the brain then re-emits
29
- * the credential in its own wire posture (anthropic `x-api-key`, openai `Bearer`), making the
30
- * per-call key the request's single credential. Case-insensitivity matters twice: a
31
- * construction-time `Authorization` (capital A) is what boot env tokens ship, and a case-variant
32
- * duplicate would otherwise ride the wire alongside the hard-locked lowercase form (fetch Headers
33
- * folds duplicates into one comma-joined value — broken auth both ways).
34
- */
35
- export declare function stripAuthHeaders(headers: Record<string, string>): void;
25
+ /** The auth-bearing header names (case-fold), single-sourced for the credential enforcement
26
+ * (`applyRouteCredentialHeaders`, route-adjudicator.ts the ONE authority over auth spelling: a
27
+ * per-model winner strips every carrier, any case, and re-asserts the winning bag's own),
28
+ * {@link mergeHeaders} (which EXEMPTS them see there), and the route credential resolver (which
29
+ * reads carrier PRESENCE per bag to classify a headers-borne credential). The former per-call
30
+ * strip helper that lived here (`stripAuthHeaders` "per-call auth replaces construction-time
31
+ * auth", triggered on `options.apiKey` presence) is retired: the three brains now run the
32
+ * route-credential resolution instead, whose per-model arm strips a strict superset of what the
33
+ * helper did. */
34
+ export declare const AUTH_CARRIER_NAMES: ReadonlySet<string>;
36
35
  /**
37
36
  * #343 — the shared USER-HEADER merge layer (`model.headers` → construction `config.headers` →
38
37
  * per-call `options.headers`, later bag wins), CASE-FOLD deduplicated: HTTP header field names are
@@ -45,26 +44,29 @@ export declare function stripAuthHeaders(headers: Record<string, string>): void;
45
44
  * deployment — is byte-identical on the wire).
46
45
  *
47
46
  * EXEMPT: the auth carriers (`authorization` / `x-api-key`, any case) pass through with the exact
48
- * legacy spread semantics (same-spelling override only, no case-fold dedup), so that
49
- * {@link stripAuthHeaders} stays the ONE authority over auth spelling and this layer never becomes a
50
- * second, subtly different one.
47
+ * legacy spread semantics (same-spelling override only, no case-fold dedup), so that the credential
48
+ * enforcement (`applyRouteCredentialHeaders`, route-adjudicator.ts) stays the ONE authority over
49
+ * auth spelling and this layer never becomes a second, subtly different one.
51
50
  *
52
51
  * RE-RULED, because the exemption used to be justified by a reason that does not hold: the
53
52
  * note claimed it protected "the header-only ANTHROPIC_AUTH_TOKEN shape, which must survive under its
54
53
  * own capital-A spelling". Dedup would not endanger that shape — it keeps the WINNER'S spelling, and a
55
54
  * lone `Authorization` has nothing to be deduped against, so it survives either way; nor does the
56
- * per-call-replaces flow depend on the exemption, since {@link stripAuthHeaders} already deletes every
57
- * spelling present. Measured, not reasoned: `mergeHeaders({Authorization:A},{authorization:B})` keeps
58
- * BOTH, and the platform `Headers` fold sends `authorization: A, B`.
55
+ * credential-replaces flow depend on the exemption, since the enforcement already deletes every
56
+ * spelling present when a per-model credential wins. Measured, not reasoned:
57
+ * `mergeHeaders({Authorization:A},{authorization:B})` keeps BOTH, and the platform `Headers` fold
58
+ * sends `authorization: A, B`.
59
59
  *
60
- * STATED RESIDUAL (deliberately not fixed here): a deployment that spells the SAME auth carrier two
61
- * ways across two layers therefore ships both, comma-folded the very disease this function fixed
62
- * for every other header. It is held, not denied, on severity: no server accepts a comma-joined
63
- * credential, so the failure is a LOUD 401 attributable to the misconfiguration, whereas the
64
- * non-auth case this function exists for produced a silently WRONG value (`X-Tenant: a, b` neither
65
- * writer's, the later layer's documented override defeated). Tightening it changes which credential
66
- * reaches the wire, so it belongs in a window that discloses an auth-face behavior change, not in one
67
- * whose subject is the reasoning knob.
60
+ * STATED RESIDUAL (deliberately not fixed here), NARROWED by the credential enforcement: where a
61
+ * PER-MODEL credential wins (an options- or entry-bag carrier), every carrier is stripped and only
62
+ * the winning bag's re-asserted, so a cross-layer double spelling now folds to the winner. What
63
+ * remains is the DEPLOYMENT-CONFIG arm its bytes ride untouched by design so a config bag that
64
+ * itself spells the SAME auth carrier two ways still ships both, comma-folded. It is held, not
65
+ * denied, on severity: no server accepts a comma-joined credential, so the failure is a LOUD 401
66
+ * attributable to the misconfiguration, whereas the non-auth case this function exists for produced
67
+ * a silently WRONG value (`X-Tenant: a, b` — neither writer's, the later layer's documented
68
+ * override defeated). Tightening it changes which credential reaches the wire, so it belongs in a
69
+ * window that discloses an auth-face behavior change.
68
70
  */
69
71
  /**
70
72
  * #343 (review r4) — assign a STRUCTURAL locked header under its canonical lowercase name, deleting
@@ -72,8 +74,9 @@ export declare function stripAuthHeaders(headers: Record<string, string>): void;
72
74
  * the user-bag merge precisely so they "can NEVER be overridden" (council design/40) — but a valid
73
75
  * user bag carrying `Content-Type: text/plain` survived BESIDE the lowercase lock, and the platform
74
76
  * `Headers` fold turns the pair into `text/plain, application/json` on the wire: the lock decided
75
- * nothing. Auth carriers are deliberately NOT routed through here (per-call replacement + the
76
- * header-only boot flow are {@link stripAuthHeaders}' pinned jurisdiction).
77
+ * nothing. Auth carriers are deliberately NOT routed through here (credential replacement + the
78
+ * header-only boot flow are the pinned jurisdiction of `applyRouteCredentialHeaders`,
79
+ * route-adjudicator.ts).
77
80
  */
78
81
  export declare function lockHeader(headers: Record<string, string>, lowerName: string, value: string): void;
79
82
  /**
@@ -54,13 +54,7 @@ export function applyExtraBody(body, extraBody, reserved) {
54
54
  }
55
55
  return { ...passthrough, ...body };
56
56
  }
57
- const AUTH_CARRIER_NAMES = new Set(["authorization", "x-api-key"]);
58
- export function stripAuthHeaders(headers) {
59
- for (const k of Object.keys(headers)) {
60
- if (AUTH_CARRIER_NAMES.has(k.toLowerCase()))
61
- delete headers[k];
62
- }
63
- }
57
+ export const AUTH_CARRIER_NAMES = new Set(["authorization", "x-api-key"]);
64
58
  export function lockHeader(headers, lowerName, value) {
65
59
  for (const k of Object.keys(headers)) {
66
60
  if (k !== lowerName && k.toLowerCase() === lowerName)
@@ -0,0 +1,190 @@
1
+ /**
2
+ * key↔URL route pairing adjudication — the SINGLE source of the pairing law.
3
+ *
4
+ * The invariant: **the credential a request carries must belong to the URL the request targets.**
5
+ * A request's URL half is per-ENTRY (`Model.baseUrl`, a required field) while the historical
6
+ * credential fallback was per-BRAIN-INSTANCE (`options.apiKey ?? config.apiKey`): with more than one
7
+ * entry flowing through one brain, the deployment credential silently followed EVERY entry's URL —
8
+ * including a third-party host it was never meant for. This module is what the three first-party
9
+ * brains now consult before shaping a request, what the brain decorators re-walk per hop (each
10
+ * hop/route re-resolves the three credential states for ITS model against ITS config), and what
11
+ * external consumers (a server annotating a model roster, a conformance suite) import so there is
12
+ * exactly one implementation of the law.
13
+ *
14
+ * The judgment, by credential source (see {@link RouteCredentialSource}):
15
+ * - `per-model` → OK always. A per-model credential was resolved FOR this entry (auth hook or an
16
+ * auth header on `Model.headers`); it is paired by construction, so the per-model lane pays no
17
+ * new cost.
18
+ * - `deployment-config` → OK only where the pairing is verifiable-and-true: the deployment DECLARED
19
+ * its credential's URL half (`config.baseUrl`) and the request targets exactly that root.
20
+ * A declared root with a diverging entry URL is the torn pair this module exists to stop
21
+ * (`route.credential_mismatch`). An UNDECLARED root (`config.apiKey` with no `config.baseUrl`)
22
+ * stays allowed as the `"unpinned"` posture — that is the documented single-endpoint quick-start
23
+ * shape (key on config, URL on the model) and refusing it would break every deployment built from
24
+ * it; the posture is surfaced so read faces can annotate the unverifiable pairing instead.
25
+ * - `absent` → OK for keyless deployments (nothing to protect); refused as
26
+ * `route.credential_missing` only where an entry declares its OWN URL away from a DECLARED
27
+ * deployment root — a distinct endpoint with no credential route is a configuration hole, and a
28
+ * loud refusal with a fix hint beats an unauthenticated request to a third-party host.
29
+ *
30
+ * Fail-closed direction: "no credential leak" outranks "silently works". A deployment that only
31
+ * worked because the gateway key HAPPENED to be valid at another entry's host now refuses loudly,
32
+ * and every refusal names the entry, both URL halves, and a one-line fix.
33
+ *
34
+ * Bad-value loudness (garbage / contradiction / absence):
35
+ * - garbage — non-string URL/credential inputs throw a `TypeError` here (a programmer error must
36
+ * not be laundered into a polite verdict); whitespace-only URLs normalize to "" (undeclared).
37
+ * - contradiction — the torn pair refuses with `route.credential_mismatch` + both halves named.
38
+ * - absence — `route.credential_missing` with the fix hint, exactly where absence is a hole.
39
+ */
40
+ import type { Model } from "../internal/llm.js";
41
+ import type { RouteAdjudication, RouteCredential, RoutePairingConfig, RoutePairingPosture } from "../internal/llm.js";
42
+ import type { Brain, EngineNotice } from "../core/types.js";
43
+ /** Normalize a base URL for pairing comparison, to WIRE equivalence: trim + strip trailing
44
+ * slashes, then — for a string that parses as a URL — fold the halves the wire itself treats as
45
+ * case-insensitive (scheme and host, RFC 9110) and elide the scheme's default port, while keeping
46
+ * the PATH byte-preserved (paths are case-sensitive; folding them would compare two genuinely
47
+ * different endpoints equal — a widening of exactly the leak this module exists to stop). Two
48
+ * spellings that resolve to the same origin+path on the wire therefore compare equal, and a
49
+ * refusal can never be minted over a spelling difference the wire cannot see. A value that does
50
+ * not parse as a URL keeps the raw trimmed spelling — the same fail-closed raw comparison as
51
+ * before. Non-string input throws — see the module note on garbage. */
52
+ export declare function normalizeBaseUrl(url: string | undefined): string;
53
+ /** Whether a header bag carries an auth-bearing header (any case). */
54
+ export declare function hasAuthCarrier(headers: Record<string, string> | undefined): boolean;
55
+ /**
56
+ * Resolve WHICH credential a request would carry, tagged with its source — the input half of the
57
+ * adjudication. Precedence (most-specific wins):
58
+ * 1. `options.apiKey` (per-call, from the per-model auth hook — presence, not truthiness: an
59
+ * empty string is a present credential that fails closed at emit),
60
+ * 2. an auth carrier in `options.headers` (per-call headers-borne). The bag MUST be per-model
61
+ * sourced (the per-model auth hook's headers) — that provenance is what licenses the
62
+ * "paired by construction" verdict below. A caller that folds run-wide headers into the
63
+ * per-call options must strip auth carriers first (the harness merge does), or a deployment
64
+ * credential would ride here and be judged per-model,
65
+ * 3. an auth carrier in `model.headers` (the ENTRY's own declared credential),
66
+ * 4. the deployment config (`config.apiKey` and/or an auth carrier in `config.headers` —
67
+ * deliberately ONE rank, not two: a config carrying BOTH forms is the documented gateway
68
+ * dual-auth escape shape, and both ride exactly as they always have; the pairing gate judges
69
+ * the pair as one deployment credential),
70
+ * 5. absent.
71
+ * 1–3 are `per-model`; 4 is `deployment-config`.
72
+ */
73
+ export declare function resolveRouteCredential(input: {
74
+ optionsApiKey?: string;
75
+ optionsHeaders?: Record<string, string>;
76
+ modelHeaders?: Record<string, string>;
77
+ configApiKey?: string;
78
+ configHeaders?: Record<string, string>;
79
+ }): RouteCredential;
80
+ /**
81
+ * The pairing law (module note above). Pure and deterministic: same inputs, same verdict — safe to
82
+ * call both at a resolution seat (pre-flight) and again inside a brain's request build.
83
+ */
84
+ export declare function adjudicateModelRoute(model: Pick<Model, "id" | "baseUrl">, credential: RouteCredential, config: RoutePairingConfig): RouteAdjudication;
85
+ /** Render a refusal as the one-line detail text the brains put in their thrown error (the machine
86
+ * code leads so log/transcript consumers can grep it; the fix hint closes it). */
87
+ export declare function routeRefusalText(verdict: Extract<RouteAdjudication, {
88
+ ok: false;
89
+ }>): string;
90
+ /**
91
+ * Enforce "the resolved credential is the request's ONLY credential" on an already-merged header
92
+ * bag — the per-call-auth-replaces rule, generalized to the per-model sources:
93
+ * - PER-MODEL apiKey-form winner → every auth carrier is stripped; the brain then emits the key in
94
+ * its own wire posture, the single credential on the wire (the pre-existing per-call rule).
95
+ * - PER-MODEL headers-borne winner (per-call or entry bag) → carriers are stripped and the winning
96
+ * bag's own carriers re-asserted (an entry-declared credential must survive a same- or
97
+ * variant-case deployment spelling that the plain merge order would have let win).
98
+ * - DEPLOYMENT-CONFIG winner → untouched. The historical bytes stand exactly: a header-only config
99
+ * keeps its own spelling, and a config carrying BOTH the apiKey knob and an auth header is the
100
+ * documented gateway dual-auth escape shape — the knob's hard-lock write plus the surviving
101
+ * header, both on the wire, as they always were.
102
+ * - absent → untouched (no carriers exist).
103
+ */
104
+ export declare function applyRouteCredentialHeaders(merged: Record<string, string>, credential: RouteCredential, bags: {
105
+ model?: Record<string, string>;
106
+ options?: Record<string, string>;
107
+ }): void;
108
+ /**
109
+ * The first-party brains' `adjudicateRoute` face, closed over the brain's construction config —
110
+ * EXACTLY the resolution + judgment the brain's own request build runs (single source, two call
111
+ * sites), so a pre-flight verdict and the request-time verdict can never disagree.
112
+ */
113
+ export declare function createBrainRouteJudge(config: {
114
+ apiKey?: string;
115
+ baseUrl?: string;
116
+ headers?: Record<string, string>;
117
+ }): (model: Model, perModelAuth?: {
118
+ apiKey?: string;
119
+ headers?: Record<string, string>;
120
+ }) => RouteAdjudication;
121
+ /**
122
+ * Per-entry pairing status — the read-face predicate a roster/diagnostic surface annotates entries
123
+ * with, so a torn pair is visible at configuration time instead of on the first real request.
124
+ * `judge` is a brain's optional `adjudicateRoute` face; absent ⇒ `"unknown"` (a custom brain core
125
+ * cannot see into — never guessed at).
126
+ */
127
+ export type RoutePairingStatus = `ok:${RoutePairingPosture}` | `broken:${"credential_mismatch" | "credential_missing"}` | "unknown";
128
+ export declare function routePairingStatus(brain: {
129
+ adjudicateRoute?: (model: Model, perModelAuth?: {
130
+ apiKey?: string;
131
+ headers?: Record<string, string>;
132
+ }) => RouteAdjudication | undefined;
133
+ }, model: Model, perModelAuth?: {
134
+ apiKey?: string;
135
+ headers?: Record<string, string>;
136
+ }): RoutePairingStatus;
137
+ /**
138
+ * Pre-flight a DERIVED-leg model (one the engine resolved from a role/tier/system default rather
139
+ * than an explicit caller `model`) against the deployment brain's pairing judge. The two-leg
140
+ * disposition for a broken pairing:
141
+ * - a NAMED model (explicit in the spec) refuses loudly at the brain's request gate — the caller
142
+ * asked for that exact route and gets the refusal + fix hint;
143
+ * - a DERIVED model must not sink the main-model task it serves — the seat falls back to the
144
+ * primary model and announces `route.fallback_to_primary` (the notice IS the loudness; a silent
145
+ * swap would hide the misconfiguration this whole surface exists to expose).
146
+ * Returns the verdict, or `undefined` when no judgment is possible (the brain has no judge face,
147
+ * the judge itself threw, or the auth hook threw or outran the advisory bound — in which case the
148
+ * seat proceeds unchanged and the failure/latency surfaces at its own request-time call site
149
+ * exactly as before).
150
+ *
151
+ * The hook call is BOUNDED. The pre-flight is advisory (the request gate re-runs the same law), so
152
+ * it must never widen a slow secret manager's blast radius from "the derived call is slow" to "task
153
+ * preparation is wedged": past the bound the pre-flight simply abstains. The eventual request still
154
+ * resolves the hook itself (the pre-flight result is never reused as a credential — same-inputs
155
+ * determinism of the law is what keeps the two calls' verdicts aligned, not a cached secret).
156
+ */
157
+ export declare const DERIVED_ROUTE_PREFLIGHT_HOOK_BOUND_MS = 5000;
158
+ export declare function adjudicateDerivedRoute(args: {
159
+ brain: Brain;
160
+ model: Model;
161
+ getApiKeyAndHeaders?: ((model: Model) => Promise<{
162
+ apiKey?: string;
163
+ headers?: Record<string, string>;
164
+ } | undefined> | {
165
+ apiKey?: string;
166
+ headers?: Record<string, string>;
167
+ } | undefined) | undefined;
168
+ }): Promise<RouteAdjudication | undefined>;
169
+ /**
170
+ * Whether two resolved models denote the SAME route for pre-flight purposes: identical wire id AND
171
+ * identical normalized entry URL. `Model.id` alone is NOT route identity — two catalog entries can
172
+ * legitimately share a wire id (the same model served from two endpoints), and a same-id guard
173
+ * would silently skip the pre-flight for exactly the split the pairing law exists to judge.
174
+ */
175
+ export declare function sameRouteIdentity(a: Pick<Model, "id" | "baseUrl">, b: Pick<Model, "id" | "baseUrl">): boolean;
176
+ /** The `route.fallback_to_primary` announcement — one shared shape for every derived-leg seat, so
177
+ * a consumer can rely on `detail.{seat, from, to, cause}` regardless of which seat fell back. */
178
+ export declare function fallbackToPrimaryNotice(args: {
179
+ seat: string;
180
+ from: string;
181
+ to: string;
182
+ verdict: Extract<RouteAdjudication, {
183
+ ok: false;
184
+ }>;
185
+ }): EngineNotice;
186
+ /** The auth carriers of a header bag, canonicalized (case-fold name → value, sorted) — the
187
+ * Model-visible credential fingerprint used by catalog-change disclosure (a same-name entry whose
188
+ * URL moved while this fingerprint did not is the "address changed, credential reference did not"
189
+ * shape worth announcing). */
190
+ export declare function authCarrierFingerprint(headers: Record<string, string> | undefined): string;
@@ -0,0 +1,189 @@
1
+ import { AUTH_CARRIER_NAMES } from "./request-params.js";
2
+ export function normalizeBaseUrl(url) {
3
+ if (url === undefined)
4
+ return "";
5
+ if (typeof url !== "string") {
6
+ throw new TypeError(`route pairing: baseUrl must be a string (got ${typeof url})`);
7
+ }
8
+ const raw = url.trim().replace(/\/+$/, "");
9
+ if (raw === "")
10
+ return "";
11
+ try {
12
+ const u = new URL(raw);
13
+ return `${u.protocol}//${u.host}${u.pathname}${u.search}`.replace(/\/+$/, "");
14
+ }
15
+ catch {
16
+ return raw;
17
+ }
18
+ }
19
+ export function hasAuthCarrier(headers) {
20
+ if (!headers)
21
+ return false;
22
+ for (const k of Object.keys(headers)) {
23
+ if (AUTH_CARRIER_NAMES.has(k.toLowerCase()))
24
+ return true;
25
+ }
26
+ return false;
27
+ }
28
+ export function resolveRouteCredential(input) {
29
+ const { optionsApiKey, optionsHeaders, modelHeaders, configApiKey, configHeaders } = input;
30
+ if (optionsApiKey !== undefined) {
31
+ if (typeof optionsApiKey !== "string")
32
+ throw new TypeError(`route pairing: apiKey must be a string (got ${typeof optionsApiKey})`);
33
+ return { source: "per-model", apiKey: optionsApiKey };
34
+ }
35
+ if (hasAuthCarrier(optionsHeaders))
36
+ return { source: "per-model", headersBorne: true, carrierBag: "options" };
37
+ if (hasAuthCarrier(modelHeaders))
38
+ return { source: "per-model", headersBorne: true, carrierBag: "model" };
39
+ const configHeadersBorne = hasAuthCarrier(configHeaders);
40
+ if (configApiKey !== undefined || configHeadersBorne) {
41
+ if (configApiKey !== undefined && typeof configApiKey !== "string") {
42
+ throw new TypeError(`route pairing: apiKey must be a string (got ${typeof configApiKey})`);
43
+ }
44
+ return {
45
+ source: "deployment-config",
46
+ ...(configApiKey !== undefined ? { apiKey: configApiKey } : {}),
47
+ ...(configHeadersBorne ? { headersBorne: true, carrierBag: "config" } : {}),
48
+ };
49
+ }
50
+ return { source: "absent" };
51
+ }
52
+ export function adjudicateModelRoute(model, credential, config) {
53
+ const entry = normalizeBaseUrl(model.baseUrl);
54
+ const declared = normalizeBaseUrl(config.baseUrl);
55
+ const request = entry || declared;
56
+ if (credential.source === "per-model")
57
+ return { ok: true, posture: "per-model" };
58
+ if (credential.source === "deployment-config") {
59
+ if (declared === "")
60
+ return { ok: true, posture: "unpinned" };
61
+ if (request === declared)
62
+ return { ok: true, posture: "paired" };
63
+ return {
64
+ ok: false,
65
+ code: "route.credential_mismatch",
66
+ detail: {
67
+ modelId: model.id,
68
+ entryBaseUrl: entry,
69
+ requestBaseUrl: request,
70
+ configBaseUrl: declared,
71
+ fixHint: `model "${model.id}" targets ${request} but the deployment credential is paired with ${declared}: ` +
72
+ `give the model its own credential (a per-model key via getApiKeyAndHeaders, or an auth header on Model.headers), ` +
73
+ `or serve it through a brain whose config.baseUrl is ${request}`,
74
+ },
75
+ };
76
+ }
77
+ if (declared === "" || entry === "" || request === declared)
78
+ return { ok: true, posture: "keyless" };
79
+ return {
80
+ ok: false,
81
+ code: "route.credential_missing",
82
+ detail: {
83
+ modelId: model.id,
84
+ entryBaseUrl: entry,
85
+ requestBaseUrl: request,
86
+ configBaseUrl: declared,
87
+ fixHint: `model "${model.id}" targets ${request} but no credential is configured for that endpoint ` +
88
+ `(the deployment credential root is ${declared}): supply a per-model credential ` +
89
+ `(getApiKeyAndHeaders, or an auth header on Model.headers), or serve it through a brain constructed for ${request}`,
90
+ },
91
+ };
92
+ }
93
+ export function routeRefusalText(verdict) {
94
+ return `${verdict.code}: ${verdict.detail.fixHint}`;
95
+ }
96
+ export function applyRouteCredentialHeaders(merged, credential, bags) {
97
+ if (credential.source !== "per-model")
98
+ return;
99
+ for (const k of Object.keys(merged)) {
100
+ if (AUTH_CARRIER_NAMES.has(k.toLowerCase()))
101
+ delete merged[k];
102
+ }
103
+ if (credential.headersBorne !== true)
104
+ return;
105
+ const winner = credential.carrierBag === "options" ? bags.options : bags.model;
106
+ if (!winner)
107
+ return;
108
+ for (const [k, v] of Object.entries(winner)) {
109
+ if (AUTH_CARRIER_NAMES.has(k.toLowerCase()))
110
+ merged[k] = v;
111
+ }
112
+ }
113
+ export function createBrainRouteJudge(config) {
114
+ return (model, perModelAuth) => {
115
+ const credential = resolveRouteCredential({
116
+ optionsApiKey: perModelAuth?.apiKey,
117
+ optionsHeaders: perModelAuth?.headers,
118
+ modelHeaders: model.headers,
119
+ configApiKey: config.apiKey,
120
+ configHeaders: config.headers,
121
+ });
122
+ return adjudicateModelRoute(model, credential, { baseUrl: config.baseUrl });
123
+ };
124
+ }
125
+ export function routePairingStatus(brain, model, perModelAuth) {
126
+ const verdict = brain.adjudicateRoute?.(model, perModelAuth);
127
+ if (verdict === undefined)
128
+ return "unknown";
129
+ if (verdict.ok)
130
+ return `ok:${verdict.posture}`;
131
+ return verdict.code === "route.credential_mismatch" ? "broken:credential_mismatch" : "broken:credential_missing";
132
+ }
133
+ export const DERIVED_ROUTE_PREFLIGHT_HOOK_BOUND_MS = 5_000;
134
+ export async function adjudicateDerivedRoute(args) {
135
+ const judge = args.brain.adjudicateRoute;
136
+ if (judge === undefined)
137
+ return undefined;
138
+ let auth;
139
+ if (args.getApiKeyAndHeaders !== undefined) {
140
+ let timer;
141
+ const ABSTAIN = Symbol("preflight-abstain");
142
+ try {
143
+ const bound = new Promise((resolve) => {
144
+ timer = setTimeout(() => resolve(ABSTAIN), DERIVED_ROUTE_PREFLIGHT_HOOK_BOUND_MS);
145
+ });
146
+ const hookResult = Promise.resolve(args.getApiKeyAndHeaders(args.model));
147
+ hookResult.catch(() => { });
148
+ const raced = await Promise.race([hookResult, bound]);
149
+ if (raced === ABSTAIN)
150
+ return undefined;
151
+ auth = raced;
152
+ }
153
+ catch {
154
+ return undefined;
155
+ }
156
+ finally {
157
+ if (timer !== undefined)
158
+ clearTimeout(timer);
159
+ }
160
+ }
161
+ try {
162
+ return judge(args.model, auth);
163
+ }
164
+ catch {
165
+ return undefined;
166
+ }
167
+ }
168
+ export function sameRouteIdentity(a, b) {
169
+ return a.id === b.id && normalizeBaseUrl(a.baseUrl) === normalizeBaseUrl(b.baseUrl);
170
+ }
171
+ export function fallbackToPrimaryNotice(args) {
172
+ return {
173
+ code: "route.fallback_to_primary",
174
+ message: `derived model "${args.from}" (${args.seat}) is not routable (${args.verdict.code}) — ` +
175
+ `falling back to the primary model "${args.to}". ${args.verdict.detail.fixHint}`,
176
+ detail: { seat: args.seat, from: args.from, to: args.to, cause: args.verdict.code, fixHint: args.verdict.detail.fixHint },
177
+ };
178
+ }
179
+ export function authCarrierFingerprint(headers) {
180
+ if (!headers)
181
+ return "";
182
+ const rows = [];
183
+ for (const [k, v] of Object.entries(headers)) {
184
+ const fold = k.toLowerCase();
185
+ if (AUTH_CARRIER_NAMES.has(fold))
186
+ rows.push(`${fold}=${v}`);
187
+ }
188
+ return rows.sort().join("\n");
189
+ }
@@ -0,0 +1,55 @@
1
+ import type { RouteAdjudication, RouteCredential, RoutePairingConfig } from "../internal/llm.js";
2
+ /**
3
+ * The PUBLISHED golden conformance corpus for the key↔URL pairing adjudicator
4
+ * ({@link import("./route-adjudicator.js").adjudicateModelRoute}).
5
+ *
6
+ * THE SEAM. The pairing law has exactly ONE implementation — core's `adjudicateModelRoute` — and
7
+ * every consumer that surfaces routing verdicts (a server annotating its model roster, a brain
8
+ * degradation leg replacing a hand-rolled check) MUST delegate to it rather than re-derive
9
+ * "does this key belong to this URL". This corpus is the anti-drift anchor between codebases
10
+ * (the gold-corpus discipline):
11
+ * - CORE runs it as a regression test (`test/route-adjudication.test.ts`): every vector's `expect`
12
+ * MUST equal `adjudicateModelRoute(model, credential, config)` on the `ok`/`posture`/`code`
13
+ * axes. A change to the law fails the test and updates the corpus in lockstep with the engine
14
+ * version, so the published contract can never silently drift from the implementation.
15
+ * - A CONSUMER imports this corpus from `@sema-agent/core` and runs it as a CONTRACT test against
16
+ * whatever face it wired the adjudicator into: for each vector, its surface's
17
+ * allow/annotate/refuse of the described route MUST match `expect.ok` (and forward
18
+ * `expect.code` verbatim on refusals). That proves the surface actually routes through core's
19
+ * predicate.
20
+ *
21
+ * ⚠️ CONSUMER CONTRACT: depend on `expect.ok` + `expect.code` (+ `expect.posture` if the surface
22
+ * annotates postures). `fixHint` wording is core-internal and may be reworded without notice.
23
+ *
24
+ * `leg` is ADVISORY: how the refusal is DISPOSED of in the engine's two-leg discipline — a
25
+ * `"named"` (caller-explicit) model surfaces the refusal loudly at the request gate; a `"derived"`
26
+ * (role/tier/system-default) model falls back to the primary model with a
27
+ * `route.fallback_to_primary` notice. The verdict itself is leg-independent (same law both legs).
28
+ */
29
+ export interface RouteAdjudicationVector {
30
+ /** Stable case id (appears in test output + the published contract). */
31
+ name: string;
32
+ /** What this vector proves. */
33
+ intent: string;
34
+ model: {
35
+ id: string;
36
+ baseUrl: string;
37
+ };
38
+ credential: RouteCredential;
39
+ config: RoutePairingConfig;
40
+ /** The `ok` / `posture` / `code` axes of the expected {@link RouteAdjudication}. */
41
+ expect: {
42
+ ok: true;
43
+ posture: Extract<RouteAdjudication, {
44
+ ok: true;
45
+ }>["posture"];
46
+ } | {
47
+ ok: false;
48
+ code: Extract<RouteAdjudication, {
49
+ ok: false;
50
+ }>["code"];
51
+ };
52
+ /** Advisory: how the engine's two-leg discipline disposes of this shape (see module note). */
53
+ leg?: "named" | "derived";
54
+ }
55
+ export declare const ROUTE_ADJUDICATION_CONFORMANCE_CORPUS: readonly RouteAdjudicationVector[];