/** * [ref]([ref] test 报「/model 中途切换校验探针经 POST /v1/side-query 对有效 key 返回上游 401」)—— * side-query 面的 **per-model key plane** 装配。 * * ## 病灶([ref] 立案时亲验 core 5.43.0 dist 实现行) * * core 的 side-query verb 当时**结构性没有 key 座位**: * · `core/side-query.d.ts` 的 `SideQuerySpec` 无 `apiKey` / `getApiKeyAndHeaders` 字段; * · `SideQueryDeps` = `{ brain, models?, roles? }`; * · `core/side-query.js` 铸 brain `options` 时只放 `signal/maxTokens/reasoning`,**不含 apiKey**。 * 而 openai brain 的 `buildRequest`(`brain/openai.js`)是这两行: * `const apiKey = options?.apiKey ?? config.apiKey;` ← 缺席即回落 **brain 构造时的网关 key** * `const root = (model.baseUrl || config.baseUrl || "")` ← 却**按模型**选路 * ⇒ 对带 per-model `baseUrl` + `apiKeyEnv`/`sealedApiKey` 的模型,side-query 把**主网关 key 发到 * 外部模型 URL**:用户看到上游 401,同时这是一次凭据外泄(安全轴)。 * 当时的窄修 = 一只 **key 注入 brain wrapper**(`createPerModelKeyBrain`),并在顶注写明「core 一旦 * 给 `SideQuerySpec` 补上 key 座位,本 wrapper 即可撤」,撤除信号挂机器钉。 * * ## [ref] 换装(core 5.46.0 提货,[ref] 座位到货 —— 撤除条件已满足) * * core 5.46.0 的 `SideQuerySpec` 有了 `getApiKeyAndHeaders?: TaskSpec["getApiKeyAndHeaders"]`, * `runSideQuery` 每次调用按**解析后的** `Model` 调它,并把 `{apiKey, headers}` 铸进 brain options * (`dist/core/side-query.js`:`const auth = await spec.getApiKeyAndHeaders?.(resolved.model);`)—— * 与主推理链逐字同一只座位形。于是 side-query 席**撤掉 brain wrapper**,key 供给改走 spec 座位: * · 本席不再在 side-query 路上包 `ctx.brain`,`RunnerDeps.brain` 的零扰动从「wrapper 只包一只引用」 * 降级成**结构性事实**(这条路上根本没有 wrapper); * · `headers` 半边从此有出路(wrapper 形只注得进 `apiKey`,core 的座位 `{apiKey, headers?}` 两半都收); * · 判据(`gatewayBaseUrl`)与 resolver 一样**按调用现取** —— 见下面 `createSideQueryLane` 的注。 * `createPerModelKeyBrain` **不删**:它仍是 WebFetch 摘要面(`boot/webfetch-summarize-lane.ts`)的 * key plane —— core 的 `WebFetchSummarizerOptions` 至今只有 `maxContentChars`,那一面没有座位可换 * (亲验 core 5.46.0 `dist/tools/web.d.ts`)。 * * 🔴 **poison(sealed key 不可解)上抛,绝不静默回落网关 key** —— `resolveModelApiKey` 抛 * `SealedKeyPoisonedError` 是「这个模型配了托管密钥但解不开」的响亮态;吞成 undefined 等于用共享 * 网关账号去打这只模型的上游,正是毒丸机制存在的理由(CLAUDE.md [ref] 安全轴 fail-closed)。换装后 * 这条语义由 core 承载得更彻底:座位抛 ⇒ `runSideQuery` 的 `await` 直接上抛,brain 一个字节都没发。 * * ## 具名残余已在**源头**清偿(core 5.57.0,[ref] 提货批)—— 本席的 P-DEBT 腿随之撤线 * * 曾经的具名残余是「无 per-model key 的 **off-route** 模型仍收网关 key」(codex R1-F1):座位回 * `undefined` ⇒ core 用网关 key,而选路仍按 `model.baseUrl` ⇒ 这一次调用真把共享网关凭据发给了别家 * 主机。当年**刻意不在本面收严**,理由是「与主推理链逐字同语义,单面收严 = 同一只模型跑任务能用、 * 问一句 401」,收严件挂在跨面件 [ref];补偿是一条 `P-DEBT` 计数(census 第 32 行)。 * * core 5.57.0 把这件事**在唯一正确的层**做了:`adjudicateModelRoute`(`brain/route-adjudicator.ts`) * 是配对法的单一实现,三只一方 brain(openai / anthropic / open-responses)在 `buildRequest` 里逐次 * 复判 —— 部署声明了 `config.baseUrl` 而 entry URL 不在那个根上、凭据又只有部署级那一份 ⇒ * `route.credential_mismatch` 响亮拒;连部署级凭据都没有 ⇒ `route.credential_missing`。**三面同批** * (主推理链 / side-query / WebFetch 摘要走的是同一只 brain 栈),旋钮 = 给模型自己的凭据、或不声明 * `config.baseUrl`(core 的 `"unpinned"` 快速起步姿态)。⇒ [ref] 要的「三面同批 + 旋钮」由上游一次交齐。 * * 于是本席的 `isOffRouteBaseUrl` + `recordFailOpen("server.model-key.off-route-model-uses-gateway-key")` * 两件**一并撤线**:那条计数的语义是「这一次调用真把网关凭据发给了别家」,而在 5.57 上那次调用根本 * 发不出去(core 在 brain 的 request gate 上先拒)—— 留着它只会在遥测里记一条与事实相反的债。 * 撤线同批:`docs/FAIL-OPEN-CENSUS.md` 第 32 行改终态、`observability/fail-open.ts` 销 tag。 * 新语义的钉在 `test/side-query-key-plane.test.ts`(off-root 拒格 + 同根回落格)。 */ import { type Brain, type SideQueryResult, type SideQuerySpec } from "@sema-agent/core"; import type { ServiceConfig } from "../config-types.js"; import type { ModelKeyRef } from "../key-resolver.js"; /** 与 `hook-llm` / `resolve-spec` 同一只座位类型(`createKeyResolver` 的产物;registry 热应用会整个换引用)。 */ export type KeyResolver = ((model: ModelKeyRef) => Promise<{ apiKey: string; } | undefined>) | undefined; /** core 5.46.0 起 side-query 与任务面共用的 per-model auth 座(`SideQuerySpec.getApiKeyAndHeaders`)。 */ export type PerModelAuthSeat = NonNullable; /** side-query 执行席:HTTP 路由(`routes/side-query.ts`)唯一的 brain 出口。 */ export type SideQueryLane = (spec: SideQuerySpec) => Promise; export interface SideQueryLaneCtx { /** 与 `RunnerDeps.brain` **同一只**实例([ref] 换装后本席不再包它 —— key 走 spec 座位)。 */ brain: Brain; /** 活配置引用:目录/角色表/网关地址被 config-center 就地热应用(`mutateInPlace`),按调用取值才跟得上。 */ config: ServiceConfig; /** 活引用取值 —— registry 热应用整个换 resolver 引用(`boot/config-center.ts` 每次 apply 重铸)。 */ getKeyResolver: () => KeyResolver; } /** * per-model auth 座([ref],core 5.46.0 `SideQuerySpec.getApiKeyAndHeaders` 的 server 侧填充物)。 * * 语义(逐条对齐主推理链 `getApiKeyAndHeaders`,也逐条对齐它取代的 wrapper 那三条臂): * · `upstream`(spec 自带的座位)**解出凭据** ⇒ 原样透传,含 `headers`(上游/装饰器已经定了凭据, * 本层不抢 —— wrapper 时代的「`options.apiKey` 已在场」臂);判别按**每次调用**的返回值,不是 * 「座位在场就整只让位」:上游对这只模型没意见时,本席照常按模型解 key; * · resolver 缺席(部署根本没配 per-model key)⇒ 回 `undefined` ⇒ core 用网关 key(今日行为逐字不变); * · resolver 返回 undefined(这只模型没有自己的 key)⇒ 回 `undefined` ⇒ 网关 key(additive 契约); * · resolver 抛(sealed key 中毒)⇒ **上抛**,`runSideQuery` 的 `await` 直接把它扔给调用方, * 本次调用整体失败,一个字节都不发。 * * 🔴 [ref] [PARTIAL high](2026-08-19 立;**2026-08-24 / core 5.57.0 源头清偿,本席腿撤线**)—— * 「无 per-model key 的 off-route 模型仍收网关 key」这条具名残余的收严件曾挂在跨面件 [ref],补偿是一条 * `P-DEBT` 计数。core 5.57.0 的 `adjudicateModelRoute` 在**三只一方 brain 的 request gate** 上把它改成 * 响亮拒(`route.credential_mismatch` / `route.credential_missing`),三面同批、旋钮成文 ⇒ 那条计数的 * 前提(「这一次调用真把网关凭据发给了别家」)不再成立,`isOffRouteBaseUrl` 与 `recordFailOpen` 一并 * 撤线(逐条理由见模块顶注)。本座位从此**只做一件事**:按模型解 per-model 凭据,解不出就交回 * `undefined` 让 core 按它自己的配对法判。 */ export declare function createPerModelAuthSeat(getKeyResolver: () => KeyResolver, opts?: { readonly upstream?: SideQuerySpec["getApiKeyAndHeaders"]; }): PerModelAuthSeat; /** * per-model key 注入 brain wrapper —— **[ref] 后只服务 WebFetch 摘要面** * (`boot/webfetch-summarize-lane.ts`)。side-query 面已换到 core 的 spec 座位(见顶注「[ref] 换装」); * core 的 `WebFetchSummarizerOptions` 至今只有 `maxContentChars`(亲验 5.46.0 `dist/tools/web.d.ts`), * 那一面**没有座位可换**,只能继续在 brain 层补凭据。 * * 语义(与 `createPerModelAuthSeat` 同一张表,只是注入位在 `options.apiKey`): * · `options.apiKey` **已在场** ⇒ 原样透传(上游/装饰器已经定了凭据,本层不抢); * · resolver 缺席 / 返回 undefined ⇒ 不注入 ⇒ 网关 key(additive 契约); * · resolver 抛(sealed key 中毒)⇒ **上抛**,本次调用整体失败,一个字节都不发。 * * `complete` 在场时同样包一层:core 的 `Brain.complete` 是 compaction/摘要腿的直调口(WebFetch 摘要器 * 正是**优先**走它),漏包等于给同一个病留第二条路。 */ export declare function createPerModelKeyBrain(brain: Brain, getKeyResolver: () => KeyResolver): Brain; /** * 装配 side-query 执行席 = 真 `runSideQuery` + core 的 per-model auth 座,models/roles 与 Runner 同源。 * * **按调用**取两件活值(`models` / `getKeyResolver`),因为它们是**同一次** center 下发里成对换代的 * ([ref] codex R1-F2:目录与密钥表零 await 窗成对换代): * · `models` = `expandTiers(config.models, config.tiers)` —— 档位词 / CC 别名(`flash`/`opus`/…)在 * side-query 上照常可选路(core 的 `Runner` 构造时做同一件事),且 `Runner` 那次展开是**构造时快照**, * 而本路由的模型白名单门(`routes/side-query.ts` 的 `isModelAllowlisted`)按请求现算同一张增广目录 —— * 两边取同一份才不会出现「名单放行、选路说不认识」的分歧; * · `getKeyResolver()` —— 热应用整个换 resolver 引用,活取才跟得上(装配时快照 = 旧 key 用到重启)。 * (第三件 `config.gatewayBaseUrl` 曾是 off-route 记债判据的参照系,core 5.57.0 起判据整只上收 core 的 * 配对裁决器 —— 而 core 拿的是 brain 构造时那份 `config.baseUrl`,与本席无关,见顶注撤线段。) * * `spec` 自带的 `getApiKeyAndHeaders`(嵌入方/装饰器)**不被覆盖** —— 它作为 `upstream` 折进本席, * 每次调用先问它,它没意见时才由本席按模型解(见 `createPerModelAuthSeat` 顶注第一条臂)。 */ export declare function createSideQueryLane(ctx: SideQueryLaneCtx): SideQueryLane; //# sourceMappingURL=side-query-lane.d.ts.map