@sema-agent/core 5.56.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 (48) hide show
  1. package/CHANGELOG.md +74 -0
  2. package/dist/agents/send-message-tool.d.ts +11 -0
  3. package/dist/agents/send-message-tool.js +34 -12
  4. package/dist/agents/team.d.ts +10 -1
  5. package/dist/agents/team.js +1 -0
  6. package/dist/brain/anthropic.js +15 -5
  7. package/dist/brain/circuit-breaker.js +2 -1
  8. package/dist/brain/degrading.js +4 -1
  9. package/dist/brain/failover.js +16 -1
  10. package/dist/brain/open-responses.js +15 -5
  11. package/dist/brain/openai.js +16 -5
  12. package/dist/brain/request-params.d.ts +30 -27
  13. package/dist/brain/request-params.js +1 -7
  14. package/dist/brain/route-adjudicator.d.ts +190 -0
  15. package/dist/brain/route-adjudicator.js +189 -0
  16. package/dist/brain/route-conformance.d.ts +55 -0
  17. package/dist/brain/route-conformance.js +136 -0
  18. package/dist/brain/routing.js +8 -3
  19. package/dist/core/mcp.js +4 -4
  20. package/dist/core/memory-engine/engine.d.ts +15 -5
  21. package/dist/core/memory-engine/engine.js +3 -1
  22. package/dist/core/permission-rule-consent.d.ts +45 -0
  23. package/dist/core/permission-rule-consent.js +40 -11
  24. package/dist/core/permission-rule-model.d.ts +110 -75
  25. package/dist/core/permission-rule-model.js +61 -28
  26. package/dist/core/runner/prepare-task.js +33 -4
  27. package/dist/core/runner/runtask.d.ts +4 -1
  28. package/dist/core/runner/runtask.js +48 -0
  29. package/dist/core/scheduler.d.ts +5 -0
  30. package/dist/core/side-query.d.ts +12 -5
  31. package/dist/core/types.d.ts +32 -0
  32. package/dist/engine/harness/agent-harness.js +26 -1
  33. package/dist/engine/harness/types.d.ts +5 -1
  34. package/dist/engine/llm/types.d.ts +65 -0
  35. package/dist/index.d.ts +4 -1
  36. package/dist/index.js +3 -1
  37. package/dist/internal/llm.d.ts +1 -1
  38. package/dist/prompts/default.d.ts +2 -2
  39. package/dist/prompts/default.js +2 -0
  40. package/dist/scenarios/scenario-registry.d.ts +5 -1
  41. package/dist/scenarios/scenario-registry.js +4 -2
  42. package/dist/tools/fs/index.js +8 -1
  43. package/dist/tools/scheduler-tools.js +28 -6
  44. package/dist/tools/web.d.ts +15 -0
  45. package/dist/tools/web.js +8 -2
  46. package/dist/tools/worktree.js +2 -2
  47. package/package.json +1 -1
  48. package/test/export-surface.snapshot.json +19 -1
package/CHANGELOG.md CHANGED
@@ -1,5 +1,79 @@
1
1
  # Changelog
2
2
 
3
+ ## 5.57.0 — 2026-08-23
4
+
5
+ ### Added
6
+ - **key×URL pairing adjudicator** (#309 cross-repo case, [5060]-[5063] consensus): `adjudicateModelRoute`
7
+ is the single source of the credential-pairing law. Per-model credential = paired by construction;
8
+ the deployment-config credential passes only on the deployment's own declared root; absent credential
9
+ is legal keyless except for an entry declaring its own off-root URL. Codes `route.credential_mismatch`
10
+ / `route.credential_missing` (refusal detail carries entry id + both URL halves + a one-line fixHint,
11
+ fail-closed by ruling). The three first-party brains gate inside buildRequest (refusal terminates
12
+ before any fetch) and expose `Brain.adjudicateRoute`; routing/failover/degrading/circuit-breaker
13
+ re-judge per leg (failover serves from the paired leg; a broken degrading hop is skipped). Two-leg
14
+ disposition: a NAMED model surfaces the refusal; a DERIVED model (compaction summarize, auto-mode
15
+ classifier, suggestions) pre-flights and falls back to the primary with a `route.fallback_to_primary`
16
+ notice; `route.base_url_changed_key_unchanged` mints at swapModels. `ROUTE_ADJUDICATION_CONFORMANCE_CORPUS`
17
+ (16 vectors) exported as the cross-codebase anchor. **Narrowings (each with a fixHint)**: declared-root
18
+ deployments' off-root entries now refuse instead of silently borrowing the gateway credential; an
19
+ entry-declared auth header now wins over the deployment key. Preserved byte-identical: unpinned
20
+ quick-start, keyless single-gateway, header-only boot token, gateway dual-auth.
21
+ - **Per-model auth seats for WebFetch summarizer and team surfaces** (#417): `getApiKeyAndHeaders`
22
+ on `WebFetchSummarizerOptions` / `TeamDiscussionOptions` / `runScenario` (absent seat = byte-identical).
23
+ - **Compound-prefix permission rules** (#364 case B, ruled; registered divergence): `Bash(cd /tmp && adb pull:*)`
24
+ — leading segments byte-exact, only the final segment takes arguments. The validator accepts the
25
+ chain form (a `*` in any other position still refuses); the matcher admits on segment-count equality
26
+ + byte-prefix at a word boundary (a single-command prefix still never admits a compound; the segment
27
+ deny/ask fence stays strictly ahead of the allow lane); the suggester mints the compound-prefix
28
+ sibling at index 1 when the final segment fits the same grammar as the simple seat; the card-edit
29
+ face accepts the respelling; org tighten gains the same grammar (a compound-prefix deny is enforceable
30
+ instead of bricking its snapshot). **Widenings**: validator accepts the new form; eligible compounds
31
+ offer 2 seats; card edit + import accept it; org snapshots carrying it are enforceable. **Mixed-fleet
32
+ note**: older engines quarantine compound-prefix rows (bytes preserved, disclosed) — do not publish
33
+ compound-prefix org rules before the fleet is level.
34
+ - **Generic prefix grammar for rule suggestions** (#364 R1, CC 2.1.223 iNo corpus): the prefix arm
35
+ uses the corpus rule `^[a-z][a-z0-9]*(-[a-z0-9]+)*$` with the interpreter/wrapper blacklist; the
36
+ 125-line lexicon demotes to a bonus for multi-word deepening; ASCII-closed head-name screen.
37
+ - **`precheckEditedRuleText(text, command)`** (#428, server pickup): the card-edit face's three text
38
+ gates (spelling normalization → grammar → coverage bound) extracted to one shared body and exported
39
+ (`EditedRuleTextPrecheck`). `ok` means SUBMITTABLE (record-level gates stay with `confirmRuleApproval`);
40
+ `canonicalRule` may differ from the submitted bytes; refusal words match `edit_rejected.detail`
41
+ verbatim (`code` rides validator refusals only). The entry screens unrenderable command bytes loudly
42
+ (`config.invalid_argument`).
43
+ - **ScheduleWakeup honesty family** (#424 A-1/A-4): the wakeup intent carries `lifetime:"session"`
44
+ where the backend vouched for the reap contract; an undeclared backend keeps the historic durable
45
+ intent with receipt + description disclosure; `reason` rides `details.reason`. An incomplete
46
+ replace (stale wakeup possibly still armed) is disclosed on the receipt.
47
+ - **SendMessage completion promises follow mount truth** (#424 A-6): `notificationWired?` / `oneShot?`
48
+ seats (defaults preserve wired behavior byte-identical); the unwired arm teaches retrieval, the
49
+ one-shot arm instructs blocking retrieval now; ten sites across two wordings guarded.
50
+
51
+ ### Fixed
52
+ - **Derived-route pre-flight can never sink the main task** (pre-ship rescan P1): the judge call and
53
+ the whole seat (including URL normalization of garbage catalog values) now abstain on throw instead
54
+ of failing task preparation and leaking the acquired session view; both derived seats share the
55
+ guarded helper.
56
+ - **Route URL comparison is wire-equivalent** (rescan): scheme/host case-fold + default-port elision
57
+ (path half stays byte-ordered; parse failure falls back to raw) — case-only respellings of the same
58
+ endpoint no longer refuse as `credential_mismatch`.
59
+ - **MCP revocation copy tells the standing truth at all four outlets** (rescan; #424 A-5 completed):
60
+ the ListMcpResources arm joins the other three ("stays on this run's roster; every call refused
61
+ the same way"), with an extinction tripwire for the old sentence.
62
+ - **AgentHarness auth carriers refuse loudly at the two host seats** (rescan hardening): a
63
+ deployment-wide auth header in `streamOptions.headers` is refused with a migration hint instead of
64
+ silently riding beside per-model credentials; `stripAuthHeaders` retired (zero callers).
65
+ - **ExitWorktree parameter texts describe this tool** (#424 A-8): detached worktrees have no branch;
66
+ a changed tree is KEPT (not refused) — CC originals and reasoning recorded at the schema.
67
+ - **Read-only memory notice restores CC's recall-framing paragraph** (#424 A-9, byte-compared).
68
+ - **MCP/scheduler small truths** (rescan): the wakeup mount note is led by the `supportsSessionWakeup:false`
69
+ arm; an explicitly-declared `supportsSessionLifetime:false` backend behaves like an undeclared one
70
+ (seam defines the two spellings as one state — the refusal arm from an unshipped interim build rolled back).
71
+
72
+ ### Bench (not part of the package surface)
73
+ - LLM distillation arm for the memory benchmark (three-clause instruction arc: retained 20.8%→100%,
74
+ convergence restored; distillation quality flat with the hand-authored ceiling on this corpus's
75
+ measurable axes); live provider key no longer travels to the loopback observation plane.
76
+
3
77
  ## 5.56.0 — 2026-08-23
4
78
 
5
79
  ### Fixed
@@ -33,6 +33,17 @@ export interface SendMessageToolOptions {
33
33
  notify?: (n: TaskNotificationPayload, opts?: {
34
34
  priority?: "now" | "next" | "later";
35
35
  }) => void;
36
+ /** Whether a completion notification really reaches the sender — drives every "you will be notified"
37
+ * sentence on this face (description + four receipts), mirroring TaskOutput's `notificationWired`.
38
+ * Default = `notify !== undefined`, which is the truth on both the first-party mount and a direct
39
+ * mount that wires its own sink here. Pass `true` only when the deployment announces a finished
40
+ * agent through a channel of its own (this tool then keeps the wired wording); `false` states the
41
+ * degraded truth explicitly. */
42
+ notificationWired?: boolean;
43
+ /** RB-220 mirror of {@link import("../core/types.js").TaskSpec.oneShot} (the Runner mount fills it):
44
+ * this run's process exits when the turn ends, so no completion notification can ever land — checked
45
+ * AHEAD of {@link notificationWired}, since no amount of wiring makes a later turn exist. */
46
+ oneShot?: boolean;
36
47
  /** Steer-handle sink: the revived run re-emits a FRESH handle (design/122 risk-table contract). */
37
48
  sink?: (handle: SubagentSteerHandle) => void;
38
49
  /**
@@ -46,6 +46,21 @@ function targetLaneKey(scope, targetId) {
46
46
  const OPERATOR_CONTINUATION_CTX = Symbol("sema.operator_continuation");
47
47
  export function createSendMessageTool(opts) {
48
48
  const tier3Capable = opts.agentStore !== undefined && opts.mailbox !== undefined && opts.reviveSpawn !== undefined;
49
+ const completionMode = opts.oneShot === true ? "one_shot" : (opts.notificationWired ?? opts.notify !== undefined) ? "notified" : "silent";
50
+ const notifyWired = completionMode === "notified";
51
+ const NO_COMPLETION_NOTICE = completionMode === "one_shot"
52
+ ? "this is a ONE-SHOT submission — there is no later turn for a completion notification to land in, so retrieve its result NOW with TaskOutput(task_id, block: true) rather than ending your turn"
53
+ : "its completion is NOT announced on this mount — retrieve its status and result with TaskOutput(task_id) where mounted";
54
+ const AWAIT_COMPLETION = notifyWired
55
+ ? "Wait for its completion notification"
56
+ : completionMode === "one_shot"
57
+ ? "Wait for it NOW with TaskOutput(task_id, block: true) — this one-shot submission ends with this turn"
58
+ : "Check for its completion with TaskOutput(task_id) where mounted";
59
+ const awaitCompletion = notifyWired
60
+ ? "wait for its completion notification"
61
+ : completionMode === "one_shot"
62
+ ? "wait for it NOW with TaskOutput(task_id, block: true) — this one-shot submission ends with this turn"
63
+ : "check for its completion with TaskOutput(task_id) where mounted";
49
64
  const sendMessagePins = new Map();
50
65
  const pinGuard = (targetId, targetName, rung, to) => {
51
66
  if (rung === "other")
@@ -86,8 +101,11 @@ export function createSendMessageTool(opts) {
86
101
  `background run with its full prior conversation preserved, so don't re-explain what it already knows. Names ` +
87
102
  `keep working after an agent completes; but if a name you already messaged is later taken by a NEWER agent, the ` +
88
103
  `send is refused rather than silently redirected — re-send with the task_id of whichever agent you meant. ` +
89
- `You will be notified automatically when it completes — prefer ending your turn; do not ` +
90
- `poll. ` +
104
+ (notifyWired
105
+ ? `You will be notified automatically when it completes — prefer ending your turn; do not poll. `
106
+ : completionMode === "one_shot"
107
+ ? `This is a ONE-SHOT submission: there is no later turn for a completion notification to land in, so do NOT end your turn expecting one — wait actively with TaskOutput(task_id, block: true) when you need a continued agent's result. `
108
+ : `A finished agent is NOT announced on this mount — check on it with TaskOutput(task_id) where mounted rather than ending your turn to wait for a notification that never comes. `) +
91
109
  (tier3Capable
92
110
  ? `Continuing a finished agent works for agents with a durable record — by name or task_id, even across restarts — and for runs that retain sub-agent sessions; when neither covers it you get an honest error and should launch a new agent with the needed context instead.`
93
111
  : `Continuing a finished agent requires the run to retain sub-agent sessions; when the session was not ` +
@@ -255,7 +273,7 @@ export function createSendMessageTool(opts) {
255
273
  if (row.status === "running") {
256
274
  if (row.writerId === opts.registry.writerId) {
257
275
  return {
258
- content: `Message not sent: ${whoT3} is currently running in this process but its mid-run delivery channel is not reachable from here. Wait for its completion notification, then send again to continue it.`,
276
+ content: `Message not sent: ${whoT3} is currently running in this process but its mid-run delivery channel is not reachable from here. ${AWAIT_COMPLETION}, then send again to continue it.`,
259
277
  details: { error: "still_running", to },
260
278
  isError: true,
261
279
  };
@@ -268,7 +286,7 @@ export function createSendMessageTool(opts) {
268
286
  };
269
287
  }
270
288
  return {
271
- content: `Message not sent: ${whoT3} is running on another host — this process has no delivery channel to it. Wait for its completion notification, then send again to continue it.`,
289
+ content: `Message not sent: ${whoT3} is running on another host — this process has no delivery channel to it. ${AWAIT_COMPLETION}, then send again to continue it.`,
272
290
  details: { error: "running_elsewhere", to },
273
291
  isError: true,
274
292
  };
@@ -501,7 +519,9 @@ export function createSendMessageTool(opts) {
501
519
  const priorCount = lease.messages.length - 1;
502
520
  return {
503
521
  content: `Message sent — ${whoT3} was revived in the background with its prior context intact${priorCount > 0 ? ` (${priorCount} earlier pending message(s) delivered with it)` : ""}. ` +
504
- `You will be notified automatically when it completes. Continue with other work — do not poll.`,
522
+ (notifyWired
523
+ ? `You will be notified automatically when it completes. Continue with other work — do not poll.`
524
+ : `${NO_COMPLETION_NOTICE.charAt(0).toUpperCase()}${NO_COMPLETION_NOTICE.slice(1)}.`),
505
525
  details: { type: "send-message", status: "revived", to, task_id: handle, seq: nextSeq, ...(priorCount > 0 ? { priorMessages: priorCount } : {}) },
506
526
  };
507
527
  }
@@ -628,10 +648,10 @@ export function createSendMessageTool(opts) {
628
648
  }, { priority: "next" });
629
649
  if (delivered.ok) {
630
650
  const receiptText = delivered.disposition === "parked"
631
- ? `Message parked for ${who}: the agent finished before reading it — it will be delivered when the agent is next continued. You will be notified of its completion; continue with other work.`
651
+ ? `Message parked for ${who}: the agent finished before reading it — it will be delivered when the agent is next continued. ${notifyWired ? "You will be notified of its completion; continue with other work." : `Note: ${NO_COMPLETION_NOTICE}.`}`
632
652
  : delivered.disposition === "pending"
633
- ? `Message accepted for ${who} but delivery is UNCONFIRMED (its channel did not confirm within the wait window) — it stays queued and will deliver if the channel binds. You will be notified of the agent's completion either way; resend then if it went unanswered. ${DEDUP_RETRY_NOTE}`
634
- : `Message queued for delivery to ${who} at its next turn. If it finishes before reading it, the message may not survive — you will be notified of its completion either way; resend then if it went unanswered. Continue with other work; do not poll. ${DEDUP_RETRY_NOTE}`;
653
+ ? `Message accepted for ${who} but delivery is UNCONFIRMED (its channel did not confirm within the wait window) — it stays queued and will deliver if the channel binds. ${notifyWired ? "You will be notified of the agent's completion either way" : `Note: ${NO_COMPLETION_NOTICE}`}; resend then if it went unanswered. ${DEDUP_RETRY_NOTE}`
654
+ : `Message queued for delivery to ${who} at its next turn. If it finishes before reading it, the message may not survive — ${notifyWired ? "you will be notified of its completion either way" : NO_COMPLETION_NOTICE}; resend then if it went unanswered. ${notifyWired ? "Continue with other work; do not poll. " : ""}${DEDUP_RETRY_NOTE}`;
635
655
  return {
636
656
  content: receiptText,
637
657
  details: { type: "send-message", status: "delivered_running", disposition: delivered.disposition, to, task_id: targetId, summary },
@@ -639,7 +659,7 @@ export function createSendMessageTool(opts) {
639
659
  }
640
660
  if (delivered.reason === "no_channel") {
641
661
  return {
642
- content: `Message not sent: ${who} is still running and this deployment has no mid-run delivery channel for it. Wait for its completion notification, then SendMessage to continue it. ${DEDUP_RETRY_NOTE}`,
662
+ content: `Message not sent: ${who} is still running and this deployment has no mid-run delivery channel for it. ${AWAIT_COMPLETION}, then SendMessage to continue it. ${DEDUP_RETRY_NOTE}`,
643
663
  details: { error: "still_running", to },
644
664
  isError: true,
645
665
  };
@@ -705,7 +725,7 @@ export function createSendMessageTool(opts) {
705
725
  }
706
726
  if (ledger.get(resumeToolUseId)?.running === true) {
707
727
  return {
708
- content: `Message not sent: ${who} (or a prior follow-up to it) is still running — wait for its completion notification.`,
728
+ content: `Message not sent: ${who} (or a prior follow-up to it) is still running — ${awaitCompletion}.`,
709
729
  details: { error: "steering.still_running", to },
710
730
  isError: true,
711
731
  };
@@ -761,7 +781,9 @@ export function createSendMessageTool(opts) {
761
781
  const marker = await resume(`${fromPrefix}[${safeSummary}] ${safeMessage}`);
762
782
  return {
763
783
  content: `Message sent — ${who} resumed in the background with its prior context intact (correlation marker [${marker}]).\n` +
764
- `You will be notified automatically when it completes; its reply will carry [${marker}]. Continue with other work — do not poll.`,
784
+ (notifyWired
785
+ ? `You will be notified automatically when it completes; its reply will carry [${marker}]. Continue with other work — do not poll.`
786
+ : `Its reply will carry [${marker}], but ${NO_COMPLETION_NOTICE}.`),
765
787
  details: { type: "send-message", status: "resumed", to, task_id: targetId, marker, summary },
766
788
  };
767
789
  }
@@ -778,7 +800,7 @@ export function createSendMessageTool(opts) {
778
800
  ? `${who} reached its resume cap (${SUBAGENT_RESUME_CAP} follow-ups per agent) — relaunch a new agent instead.`
779
801
  : code === "steering.still_running"
780
802
  ?
781
- `${who} (or a prior follow-up to it) is still running — wait for its completion notification. ${DEDUP_RETRY_NOTE}`
803
+ `${who} (or a prior follow-up to it) is still running — ${awaitCompletion}. ${DEDUP_RETRY_NOTE}`
782
804
  : code === "resume.row_recycling"
783
805
  ?
784
806
  `${who}'s registry row is being adjudicated right now (a revival claim or a reap sweep holds it) — send again in a moment. ${DEDUP_RETRY_NOTE}`
@@ -1,5 +1,5 @@
1
1
  import type { Runner } from "../core/runner/runtask.js";
2
- import type { McpServerSpec, ModelRef, ModelRole, TaskResult, ToolSpec } from "../core/types.js";
2
+ import type { McpServerSpec, ModelRef, ModelRole, TaskResult, TaskSpec, ToolSpec } from "../core/types.js";
3
3
  export interface TeamMember {
4
4
  /** The member's role / specialty, e.g. "安全评审" or "performance". */
5
5
  role: string;
@@ -99,6 +99,15 @@ export interface TeamDiscussionOptions {
99
99
  denyTools?: string[];
100
100
  mcp?: McpServerSpec[];
101
101
  };
102
+ /**
103
+ * Per-model auth — MIRRORS {@link TaskSpec.getApiKeyAndHeaders} (forwarded into every member /
104
+ * summary / synthesizer run, which resolve it against their own resolved models). Without this
105
+ * seat a team whose member/synthesizer models live on per-model-credential routes had NO way to
106
+ * carry the resolver: every nested run fell to the brain's construction-time credential, which the
107
+ * key↔URL pairing gate refuses off the deployment root — the teacher/verify delegation family
108
+ * carries the same seat for the same reason.
109
+ */
110
+ getApiKeyAndHeaders?: TaskSpec["getApiKeyAndHeaders"];
102
111
  /** Progress callback. */
103
112
  onEvent?: (e: TeamEvent) => void;
104
113
  /**
@@ -117,6 +117,7 @@ export async function runTeamDiscussion(opts) {
117
117
  limits: memberLimits,
118
118
  signal: opts.signal,
119
119
  ...(opts.principal !== undefined ? { principal: opts.principal } : {}),
120
+ ...(opts.getApiKeyAndHeaders !== undefined ? { getApiKeyAndHeaders: opts.getApiKeyAndHeaders } : {}),
120
121
  }, { isDelegatedChild: true });
121
122
  tokens += res.stats.tokens + (res.stats.nested?.tokens ?? 0);
122
123
  turns += res.stats.turns + (res.stats.nested?.turns ?? 0);
@@ -5,7 +5,8 @@ import { createRepetitionPoll, parseStreamedToolArgs } from "./stream-shared.js"
5
5
  import { mintFallbackToolCallId } from "./tool-call-id.js";
6
6
  import { emitBrainTelemetry, reportReasoningWireFacts } from "./status-sink.js";
7
7
  import { errorResultMediaNote, IMAGE_OMITTED_NO_VISION, modelSupportsVision, sendableImages } from "./media-degrade.js";
8
- import { ANTHROPIC_RESERVED, OUTPUT_CAP_KEYS, applyExtraBody, effectiveOutputCap, lockHeader, mergeHeaders, stripAuthHeaders, takeHeaderCasefold } from "./request-params.js";
8
+ import { ANTHROPIC_RESERVED, OUTPUT_CAP_KEYS, applyExtraBody, effectiveOutputCap, lockHeader, mergeHeaders, takeHeaderCasefold } from "./request-params.js";
9
+ import { adjudicateModelRoute, applyRouteCredentialHeaders, createBrainRouteJudge, resolveRouteCredential, routeRefusalText } from "./route-adjudicator.js";
9
10
  import { MIN_THINKING_TOKENS, budgetCapSkipsThinking, declaredEffortLevels, reasoningBudgetShare, reasoningRequestCarried, resolveEffort } from "./reasoning.js";
10
11
  import { runStreamingBrain } from "./stream-engine.js";
11
12
  function thinkingBudget(maxTokens, share, fixed, hardCap = false) {
@@ -234,7 +235,17 @@ export function createAnthropicBrain(config = {}) {
234
235
  httpLabel: "anthropic",
235
236
  stallTimeouts: options?.stallTimeouts,
236
237
  buildRequest: (overrides) => {
237
- const apiKey = options?.apiKey ?? config.apiKey;
238
+ const routeCredential = resolveRouteCredential({
239
+ optionsApiKey: options?.apiKey,
240
+ optionsHeaders: options?.headers,
241
+ modelHeaders: model.headers,
242
+ configApiKey: config.apiKey,
243
+ configHeaders: config.headers,
244
+ });
245
+ const routeVerdict = adjudicateModelRoute(model, routeCredential, { baseUrl: config.baseUrl });
246
+ if (!routeVerdict.ok)
247
+ throw new BrainError("invalid_request", routeRefusalText(routeVerdict));
248
+ const apiKey = routeCredential.apiKey;
238
249
  const root = (model.baseUrl || config.baseUrl || "https://api.anthropic.com").replace(/\/+$/, "");
239
250
  const nonEmptyBlocks = context.systemBlocks?.filter((b) => b.text.length > 0);
240
251
  const system = nonEmptyBlocks?.length
@@ -302,8 +313,7 @@ export function createAnthropicBrain(config = {}) {
302
313
  betas.push("interleaved-thinking-2025-05-14");
303
314
  }
304
315
  const headers = mergeHeaders(model.headers, config.headers, options?.headers);
305
- if (options?.apiKey !== undefined)
306
- stripAuthHeaders(headers);
316
+ applyRouteCredentialHeaders(headers, routeCredential, { model: model.headers, options: options?.headers });
307
317
  if (betas.length > 0) {
308
318
  const existing = (takeHeaderCasefold(headers, "anthropic-beta") ?? "").split(",").map((b) => b.trim()).filter(Boolean);
309
319
  for (const beta of betas) {
@@ -680,5 +690,5 @@ export function createAnthropicBrain(config = {}) {
680
690
  },
681
691
  });
682
692
  };
683
- return { stream };
693
+ return { stream, adjudicateRoute: createBrainRouteJudge(config) };
684
694
  }
@@ -153,5 +153,6 @@ export function createCircuitBreakerBrain(inner, opts = {}) {
153
153
  })();
154
154
  return out;
155
155
  };
156
- return { stream };
156
+ const adjudicateRoute = (model, perModelAuth) => inner.adjudicateRoute?.(model, perModelAuth);
157
+ return { stream, adjudicateRoute };
157
158
  }
@@ -144,6 +144,8 @@ export function createDegradingBrain(opts) {
144
144
  break;
145
145
  if (hop.model.id === failedModelId)
146
146
  continue;
147
+ if (hop.brain.adjudicateRoute?.(hop.model)?.ok === false)
148
+ continue;
147
149
  chainPath.push(hop.model.id);
148
150
  degradeInfo = {
149
151
  from: model.id,
@@ -182,5 +184,6 @@ export function createDegradingBrain(opts) {
182
184
  })();
183
185
  return out;
184
186
  };
185
- return { stream };
187
+ const adjudicateRoute = (model, perModelAuth) => opts.primary.adjudicateRoute?.(model, perModelAuth);
188
+ return { stream, adjudicateRoute };
186
189
  }
@@ -82,5 +82,20 @@ export function createFailoverBrain(brains) {
82
82
  })();
83
83
  return out;
84
84
  };
85
- return { stream };
85
+ const adjudicateRoute = (model, perModelAuth) => {
86
+ let firstRefusal;
87
+ let sawUnjudged = false;
88
+ for (const b of brains) {
89
+ const verdict = b.adjudicateRoute?.(model, perModelAuth);
90
+ if (verdict === undefined) {
91
+ sawUnjudged = true;
92
+ continue;
93
+ }
94
+ if (verdict.ok)
95
+ return verdict;
96
+ firstRefusal ??= verdict;
97
+ }
98
+ return sawUnjudged ? undefined : firstRefusal;
99
+ };
100
+ return { stream, adjudicateRoute };
86
101
  }
@@ -5,7 +5,8 @@ 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 { OUTPUT_CAP_KEYS, RESPONSES_RESERVED, applyExtraBody, effectiveOutputCap, lockHeader, mergeHeaders, stripAuthHeaders } from "./request-params.js";
8
+ import { OUTPUT_CAP_KEYS, RESPONSES_RESERVED, applyExtraBody, effectiveOutputCap, lockHeader, mergeHeaders } from "./request-params.js";
9
+ import { adjudicateModelRoute, applyRouteCredentialHeaders, createBrainRouteJudge, resolveRouteCredential, routeRefusalText } from "./route-adjudicator.js";
9
10
  import { mintEffortWireValue, reasoningRequestCarried } from "./reasoning.js";
10
11
  import { runStreamingBrain } from "./stream-engine.js";
11
12
  const DEGENERATE_POLL_CHARS = 64;
@@ -228,7 +229,17 @@ export function createOpenResponsesBrain(config = {}) {
228
229
  httpLabel: "responses",
229
230
  stallTimeouts: options?.stallTimeouts,
230
231
  buildRequest: (overrides) => {
231
- const apiKey = options?.apiKey ?? config.apiKey;
232
+ const routeCredential = resolveRouteCredential({
233
+ optionsApiKey: options?.apiKey,
234
+ optionsHeaders: options?.headers,
235
+ modelHeaders: model.headers,
236
+ configApiKey: config.apiKey,
237
+ configHeaders: config.headers,
238
+ });
239
+ const routeVerdict = adjudicateModelRoute(model, routeCredential, { baseUrl: config.baseUrl });
240
+ if (!routeVerdict.ok)
241
+ throw new BrainError("invalid_request", routeRefusalText(routeVerdict));
242
+ const apiKey = routeCredential.apiKey;
232
243
  const root = (model.baseUrl || config.baseUrl || "").replace(/\/+$/, "");
233
244
  if (!root) {
234
245
  throw new Error("createOpenResponsesBrain: no baseUrl configured (set config.baseUrl or model.baseUrl)");
@@ -265,8 +276,7 @@ export function createOpenResponsesBrain(config = {}) {
265
276
  if (effort !== undefined)
266
277
  body.reasoning = { effort };
267
278
  const headers = mergeHeaders(model.headers, config.headers, options?.headers);
268
- if (options?.apiKey !== undefined)
269
- stripAuthHeaders(headers);
279
+ applyRouteCredentialHeaders(headers, routeCredential, { model: model.headers, options: options?.headers });
270
280
  lockHeader(headers, "content-type", "application/json");
271
281
  if (apiKey)
272
282
  headers["authorization"] = `Bearer ${apiKey}`;
@@ -738,5 +748,5 @@ export function createOpenResponsesBrain(config = {}) {
738
748
  },
739
749
  });
740
750
  };
741
- return { stream };
751
+ return { stream, adjudicateRoute: createBrainRouteJudge(config) };
742
752
  }
@@ -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)