@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.
- package/CHANGELOG.md +140 -0
- package/dist/agents/send-message-tool.d.ts +11 -0
- package/dist/agents/send-message-tool.js +81 -13
- package/dist/agents/subagent.js +250 -89
- package/dist/agents/team.d.ts +10 -1
- package/dist/agents/team.js +1 -0
- package/dist/brain/anthropic.js +15 -5
- package/dist/brain/circuit-breaker.js +2 -1
- package/dist/brain/degrading.js +4 -1
- package/dist/brain/failover.js +16 -1
- package/dist/brain/open-responses.js +15 -5
- package/dist/brain/openai.js +16 -5
- package/dist/brain/request-params.d.ts +30 -27
- package/dist/brain/request-params.js +1 -7
- package/dist/brain/route-adjudicator.d.ts +190 -0
- package/dist/brain/route-adjudicator.js +189 -0
- package/dist/brain/route-conformance.d.ts +55 -0
- package/dist/brain/route-conformance.js +136 -0
- package/dist/brain/routing.js +8 -3
- package/dist/core/auto-compaction.d.ts +17 -4
- package/dist/core/auto-compaction.js +3 -0
- package/dist/core/context-edit.d.ts +55 -6
- package/dist/core/context-edit.js +12 -1
- package/dist/core/hooks.d.ts +293 -11
- package/dist/core/hooks.js +158 -11
- package/dist/core/human-input-projection.d.ts +20 -2
- package/dist/core/human-input-projection.js +9 -0
- package/dist/core/mcp.js +4 -4
- package/dist/core/memory-engine/engine.d.ts +15 -5
- package/dist/core/memory-engine/engine.js +3 -1
- package/dist/core/permission-rule-consent.d.ts +45 -0
- package/dist/core/permission-rule-consent.js +40 -11
- package/dist/core/permission-rule-model.d.ts +110 -75
- package/dist/core/permission-rule-model.js +61 -28
- package/dist/core/permission-rules.d.ts +23 -15
- package/dist/core/permission-rules.js +40 -31
- package/dist/core/runner/prepare-task.d.ts +8 -0
- package/dist/core/runner/prepare-task.js +66 -26
- package/dist/core/runner/runtask.d.ts +4 -1
- package/dist/core/runner/runtask.js +206 -21
- package/dist/core/runner/session-rule-policy.js +5 -5
- package/dist/core/scheduler.d.ts +5 -0
- package/dist/core/session-reconcile.d.ts +32 -0
- package/dist/core/session-reconcile.js +15 -0
- package/dist/core/side-query.d.ts +12 -5
- package/dist/core/task-notification.d.ts +34 -7
- package/dist/core/task-notification.js +11 -1
- package/dist/core/task-registry-agent.d.ts +20 -3
- package/dist/core/task-registry-agent.js +31 -2
- package/dist/core/tool-policy.d.ts +14 -9
- package/dist/core/tool-policy.js +27 -22
- package/dist/core/types.d.ts +69 -11
- package/dist/core/untrusted-text.js +8 -0
- package/dist/engine/compaction/compaction.d.ts +77 -7
- package/dist/engine/compaction/compaction.js +98 -9
- package/dist/engine/compaction/utils.d.ts +4 -0
- package/dist/engine/compaction/utils.js +6 -0
- package/dist/engine/harness/agent-harness.d.ts +84 -0
- package/dist/engine/harness/agent-harness.js +114 -13
- package/dist/engine/harness/messages.d.ts +4 -2
- package/dist/engine/harness/messages.js +7 -2
- package/dist/engine/harness/types.d.ts +16 -6
- package/dist/engine/llm/types.d.ts +65 -0
- package/dist/engine/loop/types.d.ts +7 -0
- package/dist/engine/session/import-validate.js +10 -0
- package/dist/engine/session/session.js +2 -2
- package/dist/index.d.ts +4 -1
- package/dist/index.js +3 -1
- package/dist/internal/llm.d.ts +1 -1
- package/dist/orchestration/run-spec.js +8 -1
- package/dist/prompts/default.d.ts +12 -6
- package/dist/prompts/default.js +2 -0
- package/dist/scenarios/scenario-registry.d.ts +5 -1
- package/dist/scenarios/scenario-registry.js +4 -2
- package/dist/tools/fs/index.js +8 -1
- package/dist/tools/scheduler-tools.js +28 -6
- package/dist/tools/web.d.ts +15 -0
- package/dist/tools/web.js +8 -2
- package/dist/tools/worktree.js +2 -2
- package/package.json +1 -1
- package/test/export-surface.snapshot.json +19 -1
package/dist/brain/openai.js
CHANGED
|
@@ -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
|
|
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
|
|
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
|
-
|
|
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
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
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
|
-
*
|
|
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
|
-
*
|
|
57
|
-
* spelling present. Measured, not reasoned:
|
|
58
|
-
* BOTH, and the platform `Headers` fold
|
|
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)
|
|
61
|
-
*
|
|
62
|
-
*
|
|
63
|
-
*
|
|
64
|
-
*
|
|
65
|
-
*
|
|
66
|
-
*
|
|
67
|
-
*
|
|
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 (
|
|
76
|
-
* header-only boot flow are
|
|
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[];
|