From 431cae3057312338cafab6e13bffddcc8ed116ab Mon Sep 17 00:00:00 2001 From: Sisyphus Date: Fri, 18 Sep 2026 23:34:01 +0800 Subject: [PATCH 1/4] =?UTF-8?q?feat:=20runtime-info=20protocol=20=E2=80=94?= =?UTF-8?q?=20plugins=20report=20real=20model=20config=20to=20the=20proxy?= =?UTF-8?q?=20(#955)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - New per-request headers x-bili-plugin-max-output / x-bili-plugin-model (gated on x-bili-plugin, like the existing window header) - New POST /__bili/plugin/runtime-info endpoint: bootstrap + model-switch report of {agent, model, contextWindow, maxOutput, baseURL, source}, per-agent LRU table, consulted in the native-window chain directly under the per-request header report (agent+model must match) - Reported maxOutput stands in for output-headroom reservation when the request body carries no budget of its own (#924 fallback order) - dsh-native: reads ctx.llm.resolveModelInfo + ctx.agentDefaultModel (dynamic ctx.inject) and stamps model/window/max-output headers + report - opencode v1 (extractV1Outputs), opencode v2 (catalog limit.output), pi: add model/max-output stamps + change-detected runtime report - /__bili/plugin/status now exposes model, windowSource, runtimeInfo - Tests: header parsing gates, endpoint validation/LRU/model guard, change-detection with failure rollback, dsh stamp, 3 e2e chain cases - README (en/zh): runtime-info protocol table for future client ports --- README.md | 27 +++- README.zh-CN.md | 13 +- src/agent/dsh-native.ts | 95 ++++++++++++- src/agent/opencode-native.ts | 28 +++- src/agent/opencode-v2.ts | 16 ++- src/agent/pi.ts | 12 +- src/agent/shared.ts | 38 ++++++ src/plugin.ts | 104 +++++++++++++- src/server.ts | 50 ++++++- tests/dsh-native.test.ts | 53 ++++++++ tests/runtime-info.test.ts | 254 +++++++++++++++++++++++++++++++++++ 11 files changed, 676 insertions(+), 14 deletions(-) create mode 100644 tests/runtime-info.test.ts diff --git a/README.md b/README.md index 3eb9d017..03ba385e 100644 --- a/README.md +++ b/README.md @@ -207,8 +207,31 @@ At load the plugin **spawns its own proxy** (or attaches to a healthy running one — a parent-pid watchdog tears it down when the client exits), rewrites model traffic to `/bili/`, registers `compress` / `decompress` / `acp_status` as native client tools (plugin -mode), and binds the `/acp` panel to the current session. Opt-out envs: -`BILI_NATIVE_PI=0`, `BILI_NATIVE_OPENCODE=0`, `BILI_NATIVE_DSH=0`. +mode), and binds the `/acp` panel to the current session. It also reports +the client's **own model config** to the proxy (runtime-info protocol, +#955) so compression budgets use the real window instead of a registry +guess. Opt-out envs: `BILI_NATIVE_PI=0`, `BILI_NATIVE_OPENCODE=0`, +`BILI_NATIVE_DSH=0`. + +#### Runtime-info protocol (#955) + +A native plugin lives inside the client process, so it can read the model +config the client itself will use. It pushes that truth to the proxy on two +channels, and the proxy prefers it over the models.dev registry / built-in +table in the context-window chain: + +| Channel | When | Fields | +|---|---|---| +| Per-request headers (gated on `x-bili-plugin`) | every model request | `x-bili-plugin-context-window`, `x-bili-plugin-max-output`, `x-bili-plugin-model` | +| `POST /__bili/plugin/runtime-info` (loopback) | plugin bootstrap + model switch | `{agent, model, contextWindow?, maxOutput?, baseURL?, source}` | + +Resolution order for the window: `anthropic-beta` negotiation > per-request +plugin header > runtime-info table (agent+model must match) > launcher +env > route config > models.dev registry > built-in table. A reported +`maxOutput` only stands in when the request body carries no output budget +of its own. Implementations: `src/agent/pi.ts`, `src/agent/opencode-native.ts` +(v1), `src/agent/opencode-v2.ts`, `src/agent/dsh-native.ts` — other client +integrations should follow the same protocol. Notes: diff --git a/README.zh-CN.md b/README.zh-CN.md index 576fe89f..0f6a0d9b 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -125,7 +125,18 @@ bili plugin remove # 卸载(dsh 还原占位符;配置快照存 .bi dsh 用户可以完全不用 bili:`dsh plugin --profile add billion-context` 经 dsh 自己的插件通道装上同一个原生插件(pnpm 装进 profile、挂载包内 patch 层)—— 见下文 dsh 段。 -插件加载时**自拉起自己的代理**(已有健康实例则直接复用 —— 父进程 pid 看门狗在客户端退出时收掉它),把模型流量改写到 `/bili/<上游URL>`,并把 `compress` / `decompress` / `acp_status` 注册为客户端原生工具(plugin 模式),`/acp` 面板绑定当前会话。退出开关:`BILI_NATIVE_PI=0`、`BILI_NATIVE_OPENCODE=0`、`BILI_NATIVE_DSH=0`。 +插件加载时**自拉起自己的代理**(已有健康实例则直接复用 —— 父进程 pid 看门狗在客户端退出时收掉它),把模型流量改写到 `/bili/<上游URL>`,并把 `compress` / `decompress` / `acp_status` 注册为客户端原生工具(plugin 模式),`/acp` 面板绑定当前会话。插件还会把客户端**自己的模型配置**上报给代理(runtime-info 协议,#955),压缩预算用真实窗口而不是注册表猜测。退出开关:`BILI_NATIVE_PI=0`、`BILI_NATIVE_OPENCODE=0`、`BILI_NATIVE_DSH=0`。 + +#### Runtime-info 协议(#955) + +原生插件就在客户端进程里,因此能读到客户端自己将要使用的模型配置。它通过两个通道把真相推给代理,代理在上下文窗口解析链里优先采用它而不是 models.dev 注册表/内置表: + +| 通道 | 时机 | 字段 | +|---|---|---| +| 逐请求头(门控在 `x-bili-plugin`) | 每次模型请求 | `x-bili-plugin-context-window`、`x-bili-plugin-max-output`、`x-bili-plugin-model` | +| `POST /__bili/plugin/runtime-info`(回环地址) | 插件自举 + 模型切换 | `{agent, model, contextWindow?, maxOutput?, baseURL?, source}` | + +窗口解析顺序:`anthropic-beta` 协商 > 逐请求 plugin 头 > runtime-info 表(agent+model 必须匹配) > launcher 环境变量 > 路由配置 > models.dev 注册表 > 内置表。上报的 `maxOutput` 仅在请求体自带输出预算缺席时兜底。现有实现:`src/agent/pi.ts`、`src/agent/opencode-native.ts`(v1)、`src/agent/opencode-v2.ts`、`src/agent/dsh-native.ts` —— 其他客户端接入请遵循同一协议。 注意: diff --git a/src/agent/dsh-native.ts b/src/agent/dsh-native.ts index b3ce5568..c7709d94 100644 --- a/src/agent/dsh-native.ts +++ b/src/agent/dsh-native.ts @@ -33,7 +33,7 @@ import { ensureProxyRunning, LAUNCHER_DEFAULT_HOST } from "../launcher.js"; import { markNativeHost, nativeAttachOrigin, nativeBootstrapGate, nativeProxyScriptPath, proxyEnvOrigin, singleFlight } from "./native-bootstrap.js"; import { installNativeFetchIntercept, type NativeInterceptState } from "./native-intercept.js"; -import { fetchManifest, fetchProxyVersion, fetchStatus, fetchStatusLatest, forwardTool, type ManifestTool } from "./shared.js"; +import { fetchManifest, fetchProxyVersion, fetchStatus, fetchStatusLatest, forwardTool, reportRuntimeInfo, type ManifestTool } from "./shared.js"; export const name = "bili-native"; export const inject = ["tools", "commands", "agents"]; @@ -57,6 +57,12 @@ type PluginContext = { tools: { register: (definition: ToolDefinition) => unknown }; commands: { register: (command: { name: string; description: string; handler: () => Promise }) => unknown }; agents: { currentInitiator?: () => AgentLike | undefined }; + // Runtime-info sources (#955), resolved via dynamic ctx.inject when the + // host exposes them (both are core dsh services; optional so older dsh + // builds or stripped hosts keep the plugin alive without model info). + llm?: { resolveModelInfo?: (provider: string, model: string, signal?: AbortSignal) => Promise<{ context?: { contextWindow?: number }; defaultMaxTokens?: number } | undefined> }; + agentDefaultModel?: { currentSelection?: () => { provider?: string; model?: string } | undefined }; + inject?: (deps: readonly string[], callback: (sub: PluginContext) => void) => unknown; }; /** Decides whether the native bootstrap should run in this process. */ @@ -87,6 +93,62 @@ type RegisterState = { base: string | undefined; toolsReady: boolean; dead: bool const register: RegisterState = { base: undefined, toolsReady: false, dead: false, retryAt: 0, pending: undefined }; +// Runtime-info cache (#955): the host's current model selection plus what +// ctx.llm resolved for it (contextWindow / defaultMaxTokens). Written by an +// async refresh; read synchronously by headersFor on every model request. +// Stale entries never leak across a model switch: refresh() keys off the +// LIVE selection, and a changed selection re-resolves before overwriting. +type ModelInfoCache = { provider: string; model: string; contextWindow?: number; maxOutput?: number }; +const modelInfo: { cached?: ModelInfoCache; services?: { llm?: PluginContext["llm"]; agentDefaultModel?: PluginContext["agentDefaultModel"] }; refreshing: boolean } = { refreshing: false }; + +function refreshModelInfo(origin: string | undefined): void { + const svc = modelInfo.services; + if (svc === undefined || modelInfo.refreshing) return; + let selection: { provider?: string; model?: string } | undefined; + try { + selection = svc.agentDefaultModel?.currentSelection?.(); + } catch { + return; + } + const provider = selection?.provider; + const model = selection?.model; + if (typeof provider !== "string" || provider.length === 0 || typeof model !== "string" || model.length === 0) return; + if (modelInfo.cached?.provider === provider && modelInfo.cached?.model === model) return; + const resolve = svc.llm?.resolveModelInfo; + if (resolve === undefined) { + modelInfo.cached = { provider, model }; + return; + } + modelInfo.refreshing = true; + void Promise.resolve() + .then(() => resolve(provider, model)) + .then((info) => { + modelInfo.cached = { + provider, + model, + contextWindow: typeof info?.context?.contextWindow === "number" && info.context.contextWindow > 0 ? Math.floor(info.context.contextWindow) : undefined, + maxOutput: typeof info?.defaultMaxTokens === "number" && info.defaultMaxTokens > 0 ? Math.floor(info.defaultMaxTokens) : undefined, + }; + }) + .catch(() => { + // Resolution failed (transient catalog read, model offline): keep + // the model id (usable for registry lookup) without window claims. + modelInfo.cached = { provider, model }; + }) + .finally(() => { + modelInfo.refreshing = false; + if (origin !== undefined && modelInfo.cached !== undefined) { + void reportRuntimeInfo(origin, { + agent: "dsh", + model: modelInfo.cached.model, + contextWindow: modelInfo.cached.contextWindow, + maxOutput: modelInfo.cached.maxOutput, + source: "client-config", + }).catch(() => {}); + } + }); +} + function errMessage(err: unknown): string { return err instanceof Error ? err.message : String(err); } @@ -224,13 +286,39 @@ export function apply(ctx: PluginContext): void { if (!register.toolsReady) return undefined; const sid = sessionIdOf(ctx); if (sid === undefined) return undefined; - return { "x-bili-plugin": "dsh", "x-bili-plugin-conversation": sid }; + refreshModelInfo(register.base); + const headers: Record = { "x-bili-plugin": "dsh", "x-bili-plugin-conversation": sid }; + if (modelInfo.cached !== undefined) { + headers["x-bili-plugin-model"] = modelInfo.cached.model; + if (modelInfo.cached.contextWindow !== undefined) headers["x-bili-plugin-context-window"] = String(modelInfo.cached.contextWindow); + if (modelInfo.cached.maxOutput !== undefined) headers["x-bili-plugin-max-output"] = String(modelInfo.cached.maxOutput); + } + return headers; }; void state.ready.then((origin) => { if (origin !== undefined) void registerTools(ctx).catch(() => {}); }); + // Runtime-info sources (#955): bind the model services when the host + // exposes them (dynamic inject — a missing service must never keep the + // whole plugin from activating), then report once so the proxy knows the + // model config before the first request. + if (typeof ctx.inject === "function") { + try { + ctx.inject(["llm", "agentDefaultModel"], (sub) => { + modelInfo.services = { llm: sub.llm, agentDefaultModel: sub.agentDefaultModel }; + refreshModelInfo(register.base ?? state.origin); + }); + } catch { + // inject is best-effort: without the services the plugin just + // runs header-less (wire mode + registry guess), as before. + } + } else if (ctx.llm !== undefined || ctx.agentDefaultModel !== undefined) { + modelInfo.services = { llm: ctx.llm, agentDefaultModel: ctx.agentDefaultModel }; + refreshModelInfo(register.base ?? state.origin); + } + ctx.commands.register({ name: "acp", description: "Show bili context-compression status", @@ -250,6 +338,9 @@ export function _resetRegisterForTest(base: string | undefined): void { register.dead = false; register.retryAt = 0; register.pending = undefined; + modelInfo.cached = undefined; + modelInfo.services = undefined; + modelInfo.refreshing = false; } export function _stateHeadersForTest(): ((url: string) => Record | undefined) | undefined { diff --git a/src/agent/opencode-native.ts b/src/agent/opencode-native.ts index 930734fb..20e91f44 100644 --- a/src/agent/opencode-native.ts +++ b/src/agent/opencode-native.ts @@ -72,6 +72,7 @@ import { createAcpCommandHooks } from "./opencode-acp-command.js"; import { markNativeHost, nativeAttachOrigin, nativeBootstrapGate, nativeProxyScriptPath, proxyEnvOrigin, singleFlight } from "./native-bootstrap.js"; import { installNativeFetchIntercept, isModelApiUrl, readyOrigin, type NativeInterceptState } from "./native-intercept.js"; import { createOpencodeV2Setup, type V2HttpRequestEvent, type V2State } from "./opencode-v2.js"; +import { reportRuntimeInfoOnChange } from "./shared.js"; import { callLegacyAcpConfig, isLegacyAcpSession, loadLegacyAcp, type LegacyAcpModule } from "./opencode-legacy.js"; /** Decides whether the native bootstrap should run in this process. */ @@ -427,6 +428,24 @@ export function extractV1Windows(cfg: V1Config): Map { return map; } +/** Configured max output per model (runtime-info #955): opencode's provider + * models declare `limit.output` — the ceiling the client will actually + * request. Same shape as extractV1Windows. */ +export function extractV1Outputs(cfg: V1Config): Map { + const map = new Map(); + const providers = cfg.provider; + if (providers === null || typeof providers !== "object") return map; + for (const [pid, entry] of Object.entries(providers)) { + const models = entry?.models; + if (models === null || typeof models !== "object") continue; + for (const [mid, model] of Object.entries(models)) { + const o = model?.limit?.output; + if (typeof o === "number" && Number.isFinite(o) && o > 0) map.set(`${pid}/${mid}`, Math.floor(o)); + } + } + return map; +} + export interface V1NativeDeps { /** zod module (tests inject; runtime lazy-imports "zod"). */ z?: ZodLike; @@ -459,6 +478,7 @@ export function createV1ServerHooks(origin: string, ctx: V1PluginContext, deps: const isLegacy = deps.isLegacy ?? isLegacyAcpSession; const log = deps.log ?? ((msg: string) => console.log(msg)); let windows = new Map(); + let outputs = new Map(); const hooks: V1Hooks = { config: async (cfg) => { if (legacy?.configHook !== undefined) { @@ -468,6 +488,7 @@ export function createV1ServerHooks(origin: string, ctx: V1PluginContext, deps: const n = rewriteV1Providers(cfg, origin); if (n > 0) log(`[bili-opencode-native] v1: rewrote ${n} provider baseURL(s) -> ${origin}/bili/`); windows = extractV1Windows(cfg); + outputs = extractV1Outputs(cfg); }, "command.execute.before": async (input, output) => { if ((input.command === "acp" || input.command === "dcp") && legacy?.commandHook !== undefined && isLegacy(input.sessionID)) { @@ -520,8 +541,13 @@ export function createV1ServerHooks(origin: string, ctx: V1PluginContext, deps: output.headers["x-bili-plugin-conversation"] = input.sessionID; const model = input.model; if (model && typeof model.providerID === "string" && typeof model.id === "string") { - const w = windows.get(`${model.providerID}/${model.id}`); + const key = `${model.providerID}/${model.id}`; + const w = windows.get(key); if (w !== undefined) output.headers["x-bili-plugin-context-window"] = String(w); + const o = outputs.get(key); + if (o !== undefined) output.headers["x-bili-plugin-max-output"] = String(o); + output.headers["x-bili-plugin-model"] = model.id; + reportRuntimeInfoOnChange(origin, { agent: "opencode", model: model.id, contextWindow: w, maxOutput: o, source: "client-config" }); } }; const forward = deps.forward ?? ((o, conversationId, tool, args) => import("./shared.js").then((m) => m.forwardTool(o, conversationId, tool, args))); diff --git a/src/agent/opencode-v2.ts b/src/agent/opencode-v2.ts index 0ff03994..7ef228ca 100644 --- a/src/agent/opencode-v2.ts +++ b/src/agent/opencode-v2.ts @@ -41,7 +41,7 @@ // available on all observed surfaces. import { ACP_TOOLS_OPENAI, ABSORB_TOOL_OPENAI } from "../compress-tool.js"; -import { fetchProxyVersion, fetchStatus, forwardTool, proxyBaseFromEnv, proxyBaseFromUrl, reportCompactionBoundary } from "./shared.js"; +import { fetchProxyVersion, fetchStatus, forwardTool, proxyBaseFromEnv, proxyBaseFromUrl, reportCompactionBoundary, reportRuntimeInfoOnChange } from "./shared.js"; // OpenCode V2 TUI renders a synthetic message as a visible Notice row only when its display text fits the // timeline cap (~1KB): longer text renders nothing (#880). Panels go to description verbatim under the cap. @@ -86,7 +86,7 @@ interface V2CommandEditor { interface V2CatalogModelEntry { providerID?: unknown; id?: unknown; - limit?: { context?: unknown }; + limit?: { context?: unknown; output?: unknown }; } export interface V2PluginContext { @@ -118,6 +118,7 @@ const V2_BILI_TOOLS = [...ACP_TOOLS_OPENAI, ABSORB_TOOL_OPENAI].map((t) => ({ export interface V2State { proxyBase?: string; windows?: Map; + outputs?: Map; windowsAt?: number; } @@ -129,13 +130,17 @@ function refreshWindows(ctx: V2PluginContext, state: V2State): void { try { const res = await ctx.catalog?.model?.list?.(); const map = new Map(); + const outMap = new Map(); for (const m of res?.data ?? []) { const pid = typeof m.providerID === "string" ? m.providerID : ""; const id = typeof m.id === "string" ? m.id : ""; const c = m.limit?.context; if (pid && id && typeof c === "number" && Number.isFinite(c) && c > 0) map.set(`${pid}/${id}`, Math.floor(c)); + const o = m.limit?.output; + if (pid && id && typeof o === "number" && Number.isFinite(o) && o > 0) outMap.set(`${pid}/${id}`, Math.floor(o)); } if (map.size > 0) state.windows = map; + if (outMap.size > 0) state.outputs = outMap; } catch { // catalog unavailable — window header simply goes unstamped } @@ -164,8 +169,13 @@ export function createOpencodeV2Setup(options: OpencodeV2SetupOptions = {}): (ct headers.set("x-bili-plugin", "opencode"); const model = e.model; if (model && typeof model.providerID === "string" && typeof model.id === "string") { - const window = state.windows?.get(`${model.providerID}/${model.id}`); + const key = `${model.providerID}/${model.id}`; + const window = state.windows?.get(key); if (window !== undefined) headers.set("x-bili-plugin-context-window", String(window)); + const output = state.outputs?.get(key); + if (output !== undefined) headers.set("x-bili-plugin-max-output", String(output)); + headers.set("x-bili-plugin-model", model.id); + reportRuntimeInfoOnChange(state.proxyBase, { agent: "opencode", model: model.id, contextWindow: window, maxOutput: output, source: "client-config" }); } }; diff --git a/src/agent/pi.ts b/src/agent/pi.ts index cb280780..51c901a9 100644 --- a/src/agent/pi.ts +++ b/src/agent/pi.ts @@ -5,7 +5,7 @@ // minimal structural declarations — the bundled artifact imports NOTHING // from the host at runtime (the host duck-types us in). -import { detectProxyBase, fetchManifest, forwardTool, fetchStatus, fetchProxyVersion, type ManifestTool } from "./shared.js"; +import { detectProxyBase, fetchManifest, forwardTool, fetchStatus, fetchProxyVersion, reportRuntimeInfoOnChange, type ManifestTool } from "./shared.js"; type Ctx = { sessionManager?: { getSessionId?: () => string } | undefined; @@ -443,6 +443,16 @@ export function createBiliPlugin(agentOverride?: string, opts?: { retryIntervalM if (typeof window === "number" && Number.isFinite(window) && window > 0) { headers["x-bili-plugin-context-window"] = String(Math.floor(window)); } + // Runtime-info (#955): model id + configured max output. + // pi's model config exposes id / contextWindow / baseUrl; + // maxTokens lives on the model object when configured. + const modelId = ctx.model?.id; + if (typeof modelId === "string" && modelId.length > 0) { + headers["x-bili-plugin-model"] = modelId; + const maxOut = (ctx.model as { maxTokens?: unknown } | undefined)?.maxTokens; + if (typeof maxOut === "number" && Number.isFinite(maxOut) && maxOut > 0) headers["x-bili-plugin-max-output"] = String(Math.floor(maxOut)); + reportRuntimeInfoOnChange(proxyBaseForCtx(ctx), { agent, model: modelId, contextWindow: typeof window === "number" && window > 0 ? Math.floor(window) : undefined, maxOutput: typeof maxOut === "number" && maxOut > 0 ? Math.floor(maxOut) : undefined, baseURL: ctx.model?.baseUrl, source: "client-config" }); + } } } catch (err) { console.error(`bili-plugin(${agent}): header stamp skipped (${err instanceof Error ? err.message : String(err)})`); diff --git a/src/agent/shared.ts b/src/agent/shared.ts index 6b8a2de1..0a468da5 100644 --- a/src/agent/shared.ts +++ b/src/agent/shared.ts @@ -114,6 +114,44 @@ export async function reportCompactionBoundary(proxyBase: string, conversationId }, COMPACT_TIMEOUT_MS); } +export type RuntimeInfoReport = { + agent: string; + model: string; + contextWindow?: number; + maxOutput?: number; + baseURL?: string; + source?: string; +}; + +/** Runtime-info protocol (#955): push the client's OWN model config (what + * the plugin read from the host's config) to the proxy at bootstrap and on + * model switch, before any model request. Fire-and-forget by design — the + * proxy treats a missing report as "guess like before" (registry/table). */ +export async function reportRuntimeInfo(proxyBase: string, info: RuntimeInfoReport): Promise { + const { ok, status } = await fetchJson(`${proxyBase}/__bili/plugin/runtime-info`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(info), + }, STATUS_TIMEOUT_MS); + if (!ok) throw new Error(`runtime-info report failed (${status})`); +} + +// Per-agent last report (model id): a stamp fires at most one POST per model +// switch, not per request. Failed reports roll back so the next stamp retries. +const lastRuntimeReport = new Map(); + +/** Stamp-time runtime-info report (#955): no-op unless the agent's reported + * model changed (or this is the first stamp after proxy attach/spawn). + * Soft-fail — an unreachable proxy keeps wire mode exactly as before. */ +export function reportRuntimeInfoOnChange(proxyBase: string | undefined, info: RuntimeInfoReport): void { + if (proxyBase === undefined || proxyBase.length === 0) return; + if (lastRuntimeReport.get(info.agent) === info.model) return; + lastRuntimeReport.set(info.agent, info.model); + void reportRuntimeInfo(proxyBase, info).catch(() => { + lastRuntimeReport.delete(info.agent); + }); +} + export async function forwardTool(proxyBase: string, conversationId: string, tool: string, args: unknown, signal?: AbortSignal): Promise { const { ok, status, json } = await fetchJson(`${proxyBase}/__bili/plugin/tool`, { method: "POST", diff --git a/src/plugin.ts b/src/plugin.ts index b6f1d168..998cc972 100644 --- a/src/plugin.ts +++ b/src/plugin.ts @@ -56,6 +56,8 @@ export const PLUGIN_CONVERSATION_HEADER = "x-bili-plugin-conversation"; * no compress loop): the legacy extension owns compression for them. */ export const PLUGIN_BYPASS_HEADER = "x-bili-plugin-bypass"; export const PLUGIN_CONTEXT_WINDOW_HEADER = "x-bili-plugin-context-window"; +export const PLUGIN_MAX_OUTPUT_HEADER = "x-bili-plugin-max-output"; +export const PLUGIN_MODEL_HEADER = "x-bili-plugin-model"; export const PLUGIN_PROTOCOL_VERSION = 1; @@ -108,6 +110,26 @@ export function pluginReportedContextWindow(headers: Record): number | undefined { + const raw = pluginAgentHeader(headers) === undefined ? undefined : headerValue(headers, PLUGIN_MAX_OUTPUT_HEADER); + if (raw === undefined) return undefined; + const n = Number.parseInt(raw, 10); + return Number.isFinite(n) && n > 0 ? n : undefined; +} + +/** Current model id (runtime-info protocol #955). Informational + lets the + * proxy correlate the per-agent runtime table with the request's model + * before trusting the table's window. Same plugin gate. */ +export function pluginReportedModel(headers: Record): string | undefined { + if (pluginAgentHeader(headers) === undefined) return undefined; + const raw = headerValue(headers, PLUGIN_MODEL_HEADER); + return raw !== undefined && /^\S{1,256}$/.test(raw) ? raw : undefined; +} + type ConversationEntry = { sessionId: string; lastSeen: number }; type RememberedMessages = { processed: CoreMessage[]; original: CoreMessage[]; nudge?: NudgeDecision }; @@ -226,6 +248,81 @@ export function rememberPluginMessages(sessionId: string, processed: CoreMessage // — no x-bili-plugin headers required. export type PendingPluginRegister = { conversationId: string; agent: string; ts: number }; +/** Runtime-info protocol entry (#955): what the client's OWN config says it + * will run — reported at plugin bootstrap and on model switch, before (and + * independent of) any model request. Ranked in the native-window chain + * directly under the per-request header report; entries carry their model + * id and the table is only consulted when that id matches the request's + * model (a stale post-switch entry must never size a different model). */ +export type PluginRuntimeInfo = { + agent: string; + model: string; + contextWindow?: number; + maxOutput?: number; + baseURL?: string; + source: string; + ts: number; +}; + +const pluginRuntimeTable = new Map(); +const MAX_PLUGIN_RUNTIME_ENTRIES = 32; + +export function recordPluginRuntimeInfo(entry: PluginRuntimeInfo): void { + pluginRuntimeTable.delete(entry.agent); + pluginRuntimeTable.set(entry.agent, entry); + while (pluginRuntimeTable.size > MAX_PLUGIN_RUNTIME_ENTRIES) { + const oldest = pluginRuntimeTable.keys().next().value; + if (oldest === undefined) break; + pluginRuntimeTable.delete(oldest); + } +} + +/** Latest runtime-info for an agent, usable for `model` only (undefined = + * no report, or a report for a different model). */ +export function pluginRuntimeInfoFor(agent: string | undefined, model: string | undefined): PluginRuntimeInfo | undefined { + if (agent === undefined || model === undefined) return undefined; + const entry = pluginRuntimeTable.get(agent); + if (entry === undefined || entry.model !== model) return undefined; + return entry; +} + +/** Accept the bootstrap/model-switch report. Body: + * { agent, model, contextWindow?, maxOutput?, baseURL?, source?, + * conversationId? } — agent+model are required; numbers are validated. + * Loopback-gated like every /__bili/plugin/* endpoint (the route lives + * under the same admin gate in server.ts). */ +export function handlePluginRuntimeInfo(payload: string, res: import("node:http").ServerResponse): void { + let parsed: unknown; + try { + parsed = JSON.parse(payload); + } catch { + res.writeHead(400, { "content-type": "application/json" }); + res.end(JSON.stringify({ ok: false, error: "invalid JSON" })); + return; + } + const body = parsed as { agent?: unknown; model?: unknown; contextWindow?: unknown; maxOutput?: unknown; baseURL?: unknown; source?: unknown }; + const str = (v: unknown, max: number) => (typeof v === "string" && v.length > 0 && v.length <= max ? v : undefined); + const num = (v: unknown) => (typeof v === "number" && Number.isFinite(v) && v > 0 ? Math.floor(v) : undefined); + const agent = str(body.agent, 64); + const model = str(body.model, 256); + if (agent === undefined || model === undefined) { + res.writeHead(400, { "content-type": "application/json" }); + res.end(JSON.stringify({ ok: false, error: "agent and model are required" })); + return; + } + recordPluginRuntimeInfo({ + agent, + model, + contextWindow: num(body.contextWindow), + maxOutput: num(body.maxOutput), + baseURL: str(body.baseURL, 2048), + source: str(body.source, 64) ?? "client-config", + ts: Date.now(), + }); + res.writeHead(200, { "content-type": "application/json" }); + res.end(JSON.stringify({ ok: true })); +} + const MAX_PENDING_REGISTERS = 64; const pendingRegisters: PendingPluginRegister[] = []; @@ -419,9 +516,10 @@ export function handlePluginManifest(res: import("node:http").ServerResponse): v openai: withSearchContextConversationDescription([...BILI_ACP_TOOLS_OPENAI, ABSORB_TOOL_OPENAI].map(withConversationIdParam)), responses: withSearchContextConversationDescription([...BILI_ACP_TOOLS_RESPONSES, ABSORB_TOOL_RESPONSES].map(withConversationIdParam)), }, - headers: { agent: PLUGIN_AGENT_HEADER, conversation: PLUGIN_CONVERSATION_HEADER, contextWindow: PLUGIN_CONTEXT_WINDOW_HEADER }, + headers: { agent: PLUGIN_AGENT_HEADER, conversation: PLUGIN_CONVERSATION_HEADER, contextWindow: PLUGIN_CONTEXT_WINDOW_HEADER, maxOutput: PLUGIN_MAX_OUTPUT_HEADER, model: PLUGIN_MODEL_HEADER }, toolEndpoint: "/__bili/plugin/tool", statusEndpoint: "/__bili/plugin/status", + runtimeInfoEndpoint: "/__bili/plugin/runtime-info", })); } @@ -560,6 +658,9 @@ export function handlePluginStatus(conversationId: string, res: import("node:htt fallback: viaFallback || undefined, label: session.meta.label ?? null, pluginAgent: session.metadata.pluginAgent ?? null, + model: session.metadata.lastModel ?? null, + windowSource: session.metadata.lastWindowSource ?? null, + runtimeInfo: pluginRuntimeInfoFor(typeof session.metadata.pluginAgent === "string" ? session.metadata.pluginAgent : undefined, typeof session.metadata.lastModel === "string" ? session.metadata.lastModel : undefined) ?? null, contextLimit: typeof limit === "number" ? limit : null, contextTokens: session.stats.lastInputTokens, inputTokens: session.stats.inputTokens, @@ -1894,4 +1995,5 @@ export function _resetPluginStateForTest(): void { remembered.clear(); pendingRegisters.length = 0; registeredIds.clear(); + pluginRuntimeTable.clear(); } diff --git a/src/server.ts b/src/server.ts index ac9bb925..0a46dc1c 100644 --- a/src/server.ts +++ b/src/server.ts @@ -77,7 +77,7 @@ import { emitPreflightError, emitStreamError } from "./stream-error.js"; import { affinityToken, clientConversationHeader, codexTurnIdentity, preferPromptCacheKeyIdentity, type ConversationIdentity } from "./session-id.js"; import { prefixAffinity, type AnonymousAffinity } from "./prefix-affinity.js"; import { flushPrefixAffinity, hydratePrefixAffinity, scheduleAffinityPersist } from "./affinity-persist.js"; -import { consumePluginRegisterFor, flushConversations, handlePluginCompact, handlePluginManifest, handlePluginRegister, handlePluginStatus, handlePluginTool, loadConversations, pipePluginChatWithStrip, pipePluginJson, pipePluginResponsesWithStrip, pluginAgentHeader, pluginConversationHeader, pluginReportedContextWindow, recordPluginSession, rememberPluginMessages, takePendingPluginRegister } from "./plugin.js"; +import { consumePluginRegisterFor, flushConversations, handlePluginCompact, handlePluginManifest, handlePluginRegister, handlePluginRuntimeInfo, handlePluginStatus, handlePluginTool, loadConversations, pipePluginChatWithStrip, pipePluginJson, pipePluginResponsesWithStrip, pluginAgentHeader, pluginConversationHeader, pluginReportedContextWindow, pluginReportedMaxOutput, pluginRuntimeInfoFor, recordPluginSession, rememberPluginMessages, takePendingPluginRegister } from "./plugin.js"; import { setupMitm, readMitmUpstream, getBlindTunnelStats } from "./mitm.js"; import type { BiliMessage } from "acp-kernel/wire"; import { BILI_PLUGIN_BYPASS_HEADER, hardenOpenaiAssistantContent, isLoopbackAddress, inspectContextOverflow, reserveOutputHeadroom, resolveOutputHeadroomCap, shouldReserveOutputHeadroom, systemToUser, usageTotals, type WireProtocol } from "./util.js"; @@ -768,6 +768,17 @@ async function handle( return; } } + if (req.method === "POST" && req.url === "/__bili/plugin/runtime-info") { + try { + const body = await readBody(req); + handlePluginRuntimeInfo(body.toString("utf8"), res); + return; + } catch (err) { + res.writeHead(err instanceof BodyTooLargeError ? 413 : 400, { "content-type": "application/json" }); + res.end(JSON.stringify({ ok: false, error: String(err) })); + return; + } + } if (req.method === "POST" && req.url === "/__bili/plugin/compact") { try { const body = await readBody(req); @@ -970,8 +981,11 @@ async function handle( let reqPrompts: Prompts = defaultPrompts; let reqSurface: PackSurface = {}; let reqSurfacePack = "default"; + let wsSourceForLog: string | undefined; + let reqModelId: string | undefined; if (parsed && typeof parsed === "object") { const model = (parsed as { model?: string }).model; + reqModelId = typeof model === "string" ? model : undefined; if (model) { const embeddedUrl = route?.rewrittenUrl; // Native-window resolution order: (0) the client's `anthropic-beta` @@ -999,11 +1013,18 @@ async function handle( const host = (() => { try { return embeddedUrl ? new URL(embeddedUrl).host : undefined; } catch { return undefined; } })(); const betaWindow = anthropicBetaContextWindow(req.headers); const pluginWindow = pluginReportedContextWindow(req.headers); + // Runtime-table fallback for the window (#955): only when this + // request's plugin sent no window header AND the agent's latest + // runtime-info entry matches THIS request's model — a stale + // post-switch entry must never size a different model. + const runtimeEntry = pluginRuntimeInfoFor(pluginAgentHeader(req.headers), model); + const runtimeWindow = pluginWindow === undefined ? runtimeEntry?.contextWindow : undefined; const launcherWindow = launcherContextWindow(model); const configuredWindow = resolveConfiguredContextLimit(opts.routes, embeddedUrl, model); const peekWindow = peekRegistryContext(model, host); let native = betaWindow ?? pluginWindow + ?? runtimeWindow ?? launcherWindow ?? configuredWindow ?? peekWindow @@ -1014,16 +1035,17 @@ async function handle( // beta window is authoritative (the client's own runtime // negotiation), so it also clears the fallback flag. const operatorWindowTuned = resolveCompress(opts.routes, embeddedUrl, model, opts.compress).modelContextLimit !== undefined; - nativeFromFallback = !betaWindow && !pluginWindow && !launcherWindow && !peekWindow && !configuredWindow && !operatorWindowTuned; + nativeFromFallback = !betaWindow && !pluginWindow && !runtimeWindow && !launcherWindow && !peekWindow && !configuredWindow && !operatorWindowTuned; if (!native) { native = await contextFromRegistry(model, host); if (native) nativeFromFallback = false; } reqConfig = resolveRequestConfig(config, opts.routes, embeddedUrl, model, native, opts.compress); { + const wsSource = betaWindow ? "anthropic-beta" : pluginWindow ? "plugin" : runtimeWindow ? "runtime-info" : launcherWindow ? "launcher" : configuredWindow ? "configured" : peekWindow ? "registry-peek" : native ? "table-or-registry" : "default"; + wsSourceForLog = wsSource; if (!windowSourceLogged.has(model)) { windowSourceLogged.add(model); - const wsSource = betaWindow ? "anthropic-beta" : pluginWindow ? "plugin" : launcherWindow ? "launcher" : configuredWindow ? "configured" : peekWindow ? "registry-peek" : native ? "table-or-registry" : "default"; log("info", `[window] model=${model} source=${wsSource} native=${native ?? "none"} effective=${reqConfig.modelContextLimit} launcher=${launcherWindow ?? "none"} configured=${configuredWindow ?? "none"} peek=${peekWindow ?? "none"} fallback=${nativeFromFallback}`); } } @@ -1453,6 +1475,23 @@ async function handle( // ceiling (cache-only + bundled-snapshot floor, never fetches — the // source preflight's summary cap uses, #853) — through the SAME capped // reservation below. Unknown model → 0 → today's behavior. + if (!(maxOutput > 0)) { + // Runtime-info protocol (#955): the plugin reported the client's + // CONFIGURED max output (or the model's declared default, e.g. + // dsh's defaultMaxTokens) for exactly this model — outranks + // configured/registry because it is what the client will + // actually ask the upstream for. Still only a fallback: a + // max_tokens on the wire beat it above. + const fbModel0 = (parsed as { model?: string }).model; + const runtimeMax = pluginReportedMaxOutput(req.headers) ?? pluginRuntimeInfoFor(pluginAgentHeader(req.headers), fbModel0)?.maxOutput; + if (typeof runtimeMax === "number" && runtimeMax > 0) { + maxOutput = runtimeMax; + if (!headroomFallbackLogged.has(`${fbModel0 ?? "?"}|runtime-info`)) { + headroomFallbackLogged.add(`${fbModel0 ?? "?"}|runtime-info`); + log("info", `[headroom] model=${fbModel0 ?? "?"}: request carries no output budget; reserving against runtime-info max output ${runtimeMax} (#955)`); + } + } + } if (!(maxOutput > 0)) { const fbModel = (parsed as { model?: string }).model; if (fbModel) { @@ -1489,6 +1528,11 @@ async function handle( // pre-self-heal and only for plugin sessions, so wire-mode panels fell // back to a hardcoded 200K. session.metadata.effectiveContextLimit = reqConfig.modelContextLimit; + // #955 runtime-info: record the model id + window source for this + // session so /__bili/plugin/status can show them pre-first-request and + // post-hoc forensics can tell which source sized the window. + if (reqModelId !== undefined) session.metadata.lastModel = reqModelId; + session.metadata.lastWindowSource = wsSourceForLog ?? null; // Window THIS turn runs under — read by the NEXT turn's upward self-heal // to tell "context exceeded our window" (evidence) from "context fit // inside a larger window" (not evidence). #393. diff --git a/tests/dsh-native.test.ts b/tests/dsh-native.test.ts index 96f0496a..b523d675 100644 --- a/tests/dsh-native.test.ts +++ b/tests/dsh-native.test.ts @@ -285,6 +285,10 @@ function mockCtx() { const tools: RegisteredTool[] = []; const commands: Array<{ name: string; handler: () => Promise<{ kind: string; text: string }> }> = []; let initiator: { session?: { id?: unknown } } | undefined = undefined; + // #955 runtime-info sources: tests can attach llm/agentDefaultModel and + // replay them through the same dynamic ctx.inject path production uses. + let llm: { resolveModelInfo?: (provider: string, model: string) => Promise<{ context?: { contextWindow?: number }; defaultMaxTokens?: number } | undefined> } | undefined = undefined; + let agentDefaultModel: { currentSelection?: () => { provider?: string; model?: string } | undefined } | undefined = undefined; return { tools: { register: (t: RegisteredTool) => tools.push(t) }, commands: { register: (c: { name: string; handler: () => Promise<{ kind: string; text: string }> }) => commands.push(c) }, @@ -292,6 +296,15 @@ function mockCtx() { setInitiator: (i: { session?: { id?: unknown } } | undefined) => (initiator = i), registeredTools: tools, registeredCommands: commands, + inject: (deps: readonly string[], callback: (sub: unknown) => void) => { + if (deps.includes("llm") && deps.includes("agentDefaultModel") && llm !== undefined && agentDefaultModel !== undefined) { + callback({ llm, agentDefaultModel }); + } + }, + setModelServices: (l: typeof llm, a: typeof agentDefaultModel) => { + llm = l; + agentDefaultModel = a; + }, }; } @@ -409,3 +422,43 @@ test("apply() is a no-op under the kill switches", async () => { fs.rmSync(home, { recursive: true, force: true }); } }); + +test("apply() runtime-info (#955): model services stamp model/window/max-output headers", async () => { + const proxy = await startMockProxy([]); + const home = fs.mkdtempSync(path.join(os.tmpdir(), "bili-dsh-ri-")); + try { + await withEnv({ DSH_HOME: home, BILLION_CONTEXT_PROXY: proxy.origin }, async () => { + _resetRegisterForTest(proxy.origin); + const ctx = mockCtx(); + ctx.setModelServices( + { + resolveModelInfo: async (provider, model) => { + assert.equal(provider, "deepseek"); + assert.equal(model, "qwen-ri"); + return { context: { contextWindow: 262144 }, defaultMaxTokens: 32768 }; + }, + }, + { currentSelection: () => ({ provider: "deepseek", model: "qwen-ri" }) }, + ); + apply(ctx); + await waitFor(() => ctx.registeredTools.length === 1, "manifest tool registration (ri)"); + ctx.setInitiator({ session: { id: "session-ri" } }); + // First stamp may fire before the async resolveModelInfo lands — + // poll until the window header shows up. + await waitFor(() => { + const headers = _stateHeadersForTest()?.("http://example.test/v1/chat/completions"); + return headers?.["x-bili-plugin-context-window"] === "262144"; + }, "model-info refresh stamped headers"); + const headers = _stateHeadersForTest()?.("http://example.test/v1/chat/completions"); + assert.equal(headers?.["x-bili-plugin"], "dsh"); + assert.equal(headers?.["x-bili-plugin-conversation"], "session-ri"); + assert.equal(headers?.["x-bili-plugin-model"], "qwen-ri"); + assert.equal(headers?.["x-bili-plugin-context-window"], "262144"); + assert.equal(headers?.["x-bili-plugin-max-output"], "32768"); + }); + } finally { + proxy.close(); + fs.rmSync(home, { recursive: true, force: true }); + _resetRegisterForTest(undefined); + } +}); diff --git a/tests/runtime-info.test.ts b/tests/runtime-info.test.ts new file mode 100644 index 00000000..633b5ce9 --- /dev/null +++ b/tests/runtime-info.test.ts @@ -0,0 +1,254 @@ +// #955 runtime-info protocol: plugins report the client's OWN model config +// (model id / contextWindow / maxOutput) to the proxy — via per-request +// headers and a bootstrap POST — and the proxy prefers that truth over +// registry/table guessing in the native-window chain. +import assert from "node:assert/strict"; +import http from "node:http"; +import { once } from "node:events"; +import { afterEach, beforeEach, describe, it } from "node:test"; + +import { defaultConfig } from "acp-kernel"; +import { startServer, type ProxyOptions } from "../src/server.ts"; +import { SessionStore, _setStoreForTest } from "../src/persist.ts"; +import { _setForTest as setRegistryForTest } from "../src/registry.ts"; +import { + _resetPluginStateForTest, + handlePluginRuntimeInfo, + pluginReportedMaxOutput, + pluginReportedModel, + pluginRuntimeInfoFor, + recordPluginRuntimeInfo, +} from "../src/plugin.ts"; +import { extractV1Outputs } from "../src/agent/opencode-native.ts"; +import { reportRuntimeInfoOnChange } from "../src/agent/shared.ts"; + +function mockRes(): { res: http.ServerResponse; body(): string } { + let body = ""; + const res = { + writeHead: () => undefined, + end: (chunk: unknown) => { + body = String(chunk); + }, + } as unknown as http.ServerResponse; + return { res, body: () => body }; +} + +describe("runtime-info header parsing (#955)", () => { + it("max-output is honored only from a plugin request", () => { + const headers = { "x-bili-plugin": "dsh", "x-bili-plugin-max-output": "32768" }; + assert.equal(pluginReportedMaxOutput(headers), 32768); + assert.equal(pluginReportedMaxOutput({ "x-bili-plugin-max-output": "32768" }), undefined); + assert.equal(pluginReportedMaxOutput({ "x-bili-plugin": "dsh", "x-bili-plugin-max-output": "nope" }), undefined); + assert.equal(pluginReportedMaxOutput({ "x-bili-plugin": "dsh", "x-bili-plugin-max-output": "0" }), undefined); + }); + + it("model id is honored only from a plugin request and must be a bare token", () => { + assert.equal(pluginReportedModel({ "x-bili-plugin": "pi", "x-bili-plugin-model": "qwen3.5-33b" }), "qwen3.5-33b"); + assert.equal(pluginReportedModel({ "x-bili-plugin-model": "qwen" }), undefined); + assert.equal(pluginReportedModel({ "x-bili-plugin": "pi", "x-bili-plugin-model": "a b" }), undefined); + }); +}); + +describe("runtime-info endpoint handler (#955)", () => { + beforeEach(() => _resetPluginStateForTest()); + afterEach(() => _resetPluginStateForTest()); + + it("stores a valid report and rejects incomplete ones", () => { + const ok = mockRes(); + handlePluginRuntimeInfo(JSON.stringify({ agent: "dsh", model: "m1", contextWindow: 262144, maxOutput: 8192, baseURL: "http://x", source: "client-config" }), ok.res); + assert.ok(JSON.parse(ok.body()).ok); + const entry = pluginRuntimeInfoFor("dsh", "m1"); + assert.equal(entry?.contextWindow, 262144); + assert.equal(entry?.maxOutput, 8192); + + const bad = mockRes(); + handlePluginRuntimeInfo(JSON.stringify({ agent: "dsh" }), bad.res); + assert.equal(JSON.parse(bad.body()).ok, false); + + const badJson = mockRes(); + handlePluginRuntimeInfo("not json", badJson.res); + assert.equal(JSON.parse(badJson.body()).ok, false); + }); + + it("model-mismatched lookups return nothing (stale post-switch entry must not size a different model)", () => { + recordPluginRuntimeInfo({ agent: "dsh", model: "old-model", contextWindow: 1000, source: "client-config", ts: Date.now() }); + assert.equal(pluginRuntimeInfoFor("dsh", "other-model"), undefined); + assert.equal(pluginRuntimeInfoFor(undefined, "old-model"), undefined); + }); + + it("evicts oldest entries beyond the cap", () => { + for (let i = 0; i < 40; i++) { + recordPluginRuntimeInfo({ agent: `a${i}`, model: "m", source: "t", ts: Date.now() }); + } + assert.equal(pluginRuntimeInfoFor("a0", "m"), undefined); + assert.notEqual(pluginRuntimeInfoFor("a39", "m"), undefined); + }); +}); + +describe("extractV1Outputs (#955)", () => { + it("reads limit.output from provider models", () => { + const map = extractV1Outputs({ provider: { p1: { models: { m1: { limit: { context: 1000, output: 512 } }, m2: { limit: { context: 1000 } } } } } } as never); + assert.equal(map.get("p1/m1"), 512); + assert.equal(map.has("p1/m2"), false); + }); +}); + +describe("reportRuntimeInfoOnChange (#955)", () => { + const originalFetch = globalThis.fetch; + const posts: { url: string; body: unknown }[] = []; + + beforeEach(() => { + posts.length = 0; + globalThis.fetch = (async (_url: RequestInfo | URL, init?: RequestInit) => { + posts.push({ url: String(_url), body: JSON.parse(String(init?.body)) }); + return new Response(JSON.stringify({ ok: true }), { status: 200 }); + }) as typeof fetch; + }); + afterEach(() => { + globalThis.fetch = originalFetch; + }); + + it("posts once per model switch, and retries after a failure", async () => { + reportRuntimeInfoOnChange("http://proxy", { agent: "pi", model: "m1", contextWindow: 1000 }); + reportRuntimeInfoOnChange("http://proxy", { agent: "pi", model: "m1", contextWindow: 1000 }); + await new Promise((r) => setTimeout(r, 10)); + assert.equal(posts.length, 1); + assert.equal(posts[0]?.url, "http://proxy/__bili/plugin/runtime-info"); + + reportRuntimeInfoOnChange("http://proxy", { agent: "pi", model: "m2" }); + await new Promise((r) => setTimeout(r, 10)); + assert.equal(posts.length, 2); + assert.equal((posts[1]?.body as { model: string }).model, "m2"); + + reportRuntimeInfoOnChange(undefined, { agent: "pi", model: "m3" }); + await new Promise((r) => setTimeout(r, 10)); + assert.equal(posts.length, 2); + }); + + it("rolls back the dedupe key on failure so the next stamp retries", async () => { + globalThis.fetch = (async () => new Response("err", { status: 500 })) as typeof fetch; + reportRuntimeInfoOnChange("http://proxy", { agent: "pi", model: "m1" }); + await new Promise((r) => setTimeout(r, 10)); + globalThis.fetch = (async (_url: RequestInfo | URL, init?: RequestInit) => { + posts.push({ url: String(_url), body: JSON.parse(String(init?.body)) }); + return new Response(JSON.stringify({ ok: true }), { status: 200 }); + }) as typeof fetch; + reportRuntimeInfoOnChange("http://proxy", { agent: "pi", model: "m1" }); + await new Promise((r) => setTimeout(r, 10)); + assert.equal(posts.length, 1); + }); +}); + +// ---- E2E: the window chain prefers a matching runtime-info report ---- + +interface Harness { + proxyPort: number; + upstreamPort: number; + close(): Promise; +} + +async function startHarness(): Promise { + const upstream = http.createServer((req, res) => { + req.resume(); + req.on("end", () => { + res.writeHead(200, { "content-type": "application/json" }); + res.end(JSON.stringify({ id: "msg_1", role: "assistant", content: [{ type: "text", text: "ok" }], usage: { input_tokens: 5, output_tokens: 1 } })); + }); + }); + upstream.listen(0, "127.0.0.1"); + await once(upstream, "listening"); + const upstreamPort = upstream.address().port; + + _setStoreForTest(new SessionStore({ enabled: false })); + setRegistryForTest({}); + _resetPluginStateForTest(); + const proxy = await startServer({ + port: 0, + host: "127.0.0.1", + upstream: "http://127.0.0.1", + routes: { [`http://127.0.0.1:${upstreamPort}`]: { models: {} } }, + kernelConfig: defaultConfig(400_000), + compress: { injectTool: true, injectNudge: true }, + sessionHeader: "x-acp-session", + log: false, + debug: false, + passthrough: false, + autoUpdate: false, + mitm: { enabled: false, domains: [] }, + } as unknown as ProxyOptions); + await once(proxy, "listening"); + + return { + proxyPort: proxy.address().port, + upstreamPort, + close: async () => { + proxy.close(); + upstream.close(); + await Promise.allSettled([once(proxy, "close"), once(upstream, "close")]); + }, + }; +} + +describe("runtime-info in the native-window chain (#955, e2e)", () => { + let h: Harness | undefined; + beforeEach(async () => { + h = await startHarness(); + }); + afterEach(async () => { + await h?.close(); + h = undefined; + }); + + it("a matching bootstrap report sizes the window when no header carries one", async () => { + const report = await fetch(`http://127.0.0.1:${h!.proxyPort}/__bili/plugin/runtime-info`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ agent: "dsh", model: "test-model", contextWindow: 262144, maxOutput: 32768, source: "client-config" }), + }); + assert.equal(report.status, 200); + + const resp = await fetch(`http://127.0.0.1:${h!.proxyPort}/bili/http://127.0.0.1:${h!.upstreamPort}/v1/chat/completions`, { + method: "POST", + headers: { "content-type": "application/json", "x-bili-plugin": "dsh", "x-bili-plugin-conversation": "conv-ri-1" }, + body: JSON.stringify({ model: "test-model", stream: false, messages: [{ role: "user", content: "hello" }] }), + }); + assert.equal(resp.status, 200); + + const status = await (await fetch(`http://127.0.0.1:${h!.proxyPort}/__bili/plugin/status?conversationId=conv-ri-1`)).json() as { model: string | null; windowSource: string | null; runtimeInfo: { contextWindow?: number } | null }; + assert.equal(status.model, "test-model"); + assert.equal(status.windowSource, "runtime-info"); + assert.equal(status.runtimeInfo?.contextWindow, 262144); + }); + + it("a per-request window header still outranks the runtime table", async () => { + await fetch(`http://127.0.0.1:${h!.proxyPort}/__bili/plugin/runtime-info`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ agent: "dsh", model: "test-model", contextWindow: 111111, source: "client-config" }), + }); + const resp = await fetch(`http://127.0.0.1:${h!.proxyPort}/bili/http://127.0.0.1:${h!.upstreamPort}/v1/chat/completions`, { + method: "POST", + headers: { "content-type": "application/json", "x-bili-plugin": "dsh", "x-bili-plugin-conversation": "conv-ri-2", "x-bili-plugin-context-window": "222222" }, + body: JSON.stringify({ model: "test-model", stream: false, messages: [{ role: "user", content: "hello" }] }), + }); + assert.equal(resp.status, 200); + const status = await (await fetch(`http://127.0.0.1:${h!.proxyPort}/__bili/plugin/status?conversationId=conv-ri-2`)).json() as { windowSource: string | null }; + assert.equal(status.windowSource, "plugin"); + }); + + it("a report for a DIFFERENT model never sizes this request", async () => { + await fetch(`http://127.0.0.1:${h!.proxyPort}/__bili/plugin/runtime-info`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ agent: "dsh", model: "other-model", contextWindow: 111111, source: "client-config" }), + }); + const resp = await fetch(`http://127.0.0.1:${h!.proxyPort}/bili/http://127.0.0.1:${h!.upstreamPort}/v1/chat/completions`, { + method: "POST", + headers: { "content-type": "application/json", "x-bili-plugin": "dsh", "x-bili-plugin-conversation": "conv-ri-3" }, + body: JSON.stringify({ model: "test-model", stream: false, messages: [{ role: "user", content: "hello" }] }), + }); + assert.equal(resp.status, 200); + const status = await (await fetch(`http://127.0.0.1:${h!.proxyPort}/__bili/plugin/status?conversationId=conv-ri-3`)).json() as { windowSource: string | null }; + assert.notEqual(status.windowSource, "runtime-info"); + }); +}); From c1bf0e4ebe8cb0bde8e31185ddc852f9f3386045 Mon Sep 17 00:00:00 2001 From: ework-agent Date: Sat, 19 Sep 2026 00:29:05 +0800 Subject: [PATCH 2/4] fix(#955): serve /acp pre-first-request from the runtime table Acceptance criterion #1 ("dsh session: /acp before first request shows model + window from client config") was not met: the runtime-info table was only consulted once a session existed, so the latest-session probe a client without a stable id sends (conversationId=&fallback=latest) 404'd until the first model request landed. - handlePluginStatus now answers that probe from the agent-keyed runtime table (phase: "pre-first-request") when no session resolves; original 404 messages preserved otherwise - dsh statusOutcome renders the reported model/window/maxOutput/source instead of the generic "no model request yet" notice - tests: e2e pre-first-request status (report->200, empty->404, real session takes over after traffic), dsh /acp render of the probe response; harness now isolates XDG_STATE_HOME + resets the in-memory session map so "no session yet" preconditions are deterministic - README en/zh: one line documenting the pre-first-request behavior --- README.md | 6 ++++ README.zh-CN.md | 2 ++ src/agent/dsh-native.ts | 15 ++++++++ src/plugin.ts | 17 ++++++--- tests/dsh-native.test.ts | 45 +++++++++++++++++++++-- tests/runtime-info.test.ts | 73 ++++++++++++++++++++++++++++++++++++++ 6 files changed, 151 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 03ba385e..129d447d 100644 --- a/README.md +++ b/README.md @@ -233,6 +233,12 @@ of its own. Implementations: `src/agent/pi.ts`, `src/agent/opencode-native.ts` (v1), `src/agent/opencode-v2.ts`, `src/agent/dsh-native.ts` — other client integrations should follow the same protocol. +Before the first model request there is no session yet, so the `/acp` panel +probes `GET /__bili/plugin/status?conversationId=&fallback=latest`, +which answers from the runtime table (`phase: "pre-first-request"`) instead +of 404ing — the reported config is visible immediately, and the real session +takes over once traffic lands. + Notes: - Native mode is **mutually exclusive** with the standalone in-process diff --git a/README.zh-CN.md b/README.zh-CN.md index 0f6a0d9b..b3318846 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -138,6 +138,8 @@ dsh 用户可以完全不用 bili:`dsh plugin --profile add billion-con 窗口解析顺序:`anthropic-beta` 协商 > 逐请求 plugin 头 > runtime-info 表(agent+model 必须匹配) > launcher 环境变量 > 路由配置 > models.dev 注册表 > 内置表。上报的 `maxOutput` 仅在请求体自带输出预算缺席时兜底。现有实现:`src/agent/pi.ts`、`src/agent/opencode-native.ts`(v1)、`src/agent/opencode-v2.ts`、`src/agent/dsh-native.ts` —— 其他客户端接入请遵循同一协议。 +首次模型请求之前会话尚不存在,`/acp` 面板会探测 `GET /__bili/plugin/status?conversationId=&fallback=latest`,代理从 runtime-info 表应答(`phase: "pre-first-request"`)而不是返回 404 —— 上报的配置立即可见,流量落地后由真实会话接管。 + 注意: - 原生模式与独立进程内扩展(`billion-context-pi`、`opencode-acp`)**互斥** —— 安装器负责换条目并把原配置快照(`.bili-bak`);迁移细节见上方客户端表(pi 需 `billion-context-pi` 0.1.72+ 才能干净退让)。 diff --git a/src/agent/dsh-native.ts b/src/agent/dsh-native.ts index c7709d94..7f7358e2 100644 --- a/src/agent/dsh-native.ts +++ b/src/agent/dsh-native.ts @@ -246,6 +246,21 @@ async function statusOutcome(ctx: PluginContext): Promise { if (status && typeof panel === "string" && panel.length > 0) { return { kind: "success", text: panel }; } + // #955: pre-first-request view — the proxy answers from the runtime-info + // table this plugin populated at bootstrap, so /acp shows the client's + // own model config before any model request has sized a session. + const ri = status?.runtimeInfo as { model?: unknown; contextWindow?: unknown; maxOutput?: unknown; source?: unknown } | null | undefined; + if (status !== undefined && ri !== null && ri !== undefined && (typeof ri.model === "string" || typeof ri.contextWindow === "number")) { + const parts: string[] = []; + if (typeof ri.model === "string") parts.push(`model=${ri.model}`); + if (typeof ri.contextWindow === "number") parts.push(`window=${ri.contextWindow}`); + if (typeof ri.maxOutput === "number") parts.push(`maxOut=${ri.maxOutput}`); + const version = await fetchProxyVersion(base); + return { + kind: "success", + text: `billion-context${version ? `@${version}` : ""} — proxy connected, compression armed. Runtime info${typeof ri.source === "string" ? ` (${ri.source})` : ""}: ${parts.join(" ")}. No model request yet; send one, then run /acp again for the full panel.`, + }; + } const version = await fetchProxyVersion(base); if (version) { return { diff --git a/src/plugin.ts b/src/plugin.ts index 998cc972..2aa0241b 100644 --- a/src/plugin.ts +++ b/src/plugin.ts @@ -586,15 +586,22 @@ export function handlePluginStatus(conversationId: string, res: import("node:htt // #656: name the conversation that was actually resolved — the // caller asked with a stale id and must learn the real one. resolvedConversationId = conversationIdForSession(latest.id) ?? conversationId; - } else { - res.writeHead(404, { "content-type": "application/json" }); - res.end(JSON.stringify({ ok: false, error: "no session with activity since boot — issue a model request or pass the conversation id" })); - return; } } if (!session) { + // Runtime-info protocol (#955): no session exists yet, but the client + // may have reported its model config at bootstrap — answer from the + // agent-keyed runtime table so /acp works pre-first-request. Clients + // without a stable conversation id before their first request probe + // with their agent name (dsh's fetchStatusLatest sends "dsh"). + const pre = pluginRuntimeTable.get(conversationId); + if (pre !== undefined) { + res.writeHead(200, { "content-type": "application/json" }); + res.end(JSON.stringify({ ok: true, conversationId, phase: "pre-first-request", model: pre.model, contextLimit: pre.contextWindow ?? null, runtimeInfo: pre, panel: null })); + return; + } res.writeHead(404, { "content-type": "application/json" }); - res.end(JSON.stringify({ ok: false, error: "unknown plugin conversation" })); + res.end(JSON.stringify({ ok: false, error: fallbackLatest ? "no session with activity since boot — issue a model request or pass the conversation id" : "unknown plugin conversation" })); return; } if (entry) entry.lastSeen = Date.now(); diff --git a/tests/dsh-native.test.ts b/tests/dsh-native.test.ts index b523d675..d4345ceb 100644 --- a/tests/dsh-native.test.ts +++ b/tests/dsh-native.test.ts @@ -221,7 +221,7 @@ test("dshProfileDirs: skips node_modules, errors when profiles root is absent", type MockTool = { name: string; description?: string; inputSchema: unknown }; -function startMockProxy(toolCalls: Array<{ conversationId: string; tool: string; args: unknown }>): Promise<{ origin: string; close: () => void }> { +function startMockProxy(toolCalls: Array<{ conversationId: string; tool: string; args: unknown }>, statusResponder?: (url: string) => unknown | undefined): Promise<{ origin: string; close: () => void }> { const manifestTools: MockTool[] = [ { name: "compress", @@ -248,8 +248,14 @@ function startMockProxy(toolCalls: Array<{ conversationId: string; tool: string; return; } if (url.startsWith("/__bili/plugin/status")) { + const body = statusResponder === undefined ? { panel: "PANEL-OK" } : statusResponder(url); + if (body === undefined) { + res.writeHead(404); + res.end("{}"); + return; + } res.writeHead(200, { "content-type": "application/json" }); - res.end(JSON.stringify({ panel: "PANEL-OK" })); + res.end(JSON.stringify(body)); return; } res.writeHead(404); @@ -462,3 +468,38 @@ test("apply() runtime-info (#955): model services stamp model/window/max-output _resetRegisterForTest(undefined); } }); + +test("apply() /acp pre-first-request (#955): renders the runtime-table entry before any model request", async () => { + const pre = { + ok: true, + conversationId: "dsh", + phase: "pre-first-request", + model: "qwen-ri", + contextLimit: 262144, + runtimeInfo: { agent: "dsh", model: "qwen-ri", contextWindow: 262144, maxOutput: 32768, source: "client-config" }, + panel: null, + }; + // no initiator session → statusOutcome takes the fetchStatusLatest path + // (conversationId=dsh&fallback=latest), which the proxy answers from the + // agent-keyed runtime table pre-first-request + const proxy = await startMockProxy([], (url) => (url.includes("conversationId=dsh&fallback=latest") ? pre : undefined)); + const home = fs.mkdtempSync(path.join(os.tmpdir(), "bili-dsh-pre-")); + try { + await withEnv({ DSH_HOME: home, BILLION_CONTEXT_PROXY: proxy.origin }, async () => { + _resetRegisterForTest(proxy.origin); + const ctx = mockCtx(); + apply(ctx); + assert.equal(ctx.registeredCommands.length, 1); + const out = await ctx.registeredCommands[0].handler(); + assert.equal(out.kind, "success"); + assert.match(out.text, /model=qwen-ri/); + assert.match(out.text, /window=262144/); + assert.match(out.text, /maxOut=32768/); + assert.match(out.text, /client-config/); + }); + } finally { + proxy.close(); + fs.rmSync(home, { recursive: true, force: true }); + _resetRegisterForTest(undefined); + } +}); diff --git a/tests/runtime-info.test.ts b/tests/runtime-info.test.ts index 633b5ce9..424354bd 100644 --- a/tests/runtime-info.test.ts +++ b/tests/runtime-info.test.ts @@ -4,6 +4,9 @@ // registry/table guessing in the native-window chain. import assert from "node:assert/strict"; import http from "node:http"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; import { once } from "node:events"; import { afterEach, beforeEach, describe, it } from "node:test"; @@ -11,6 +14,7 @@ import { defaultConfig } from "acp-kernel"; import { startServer, type ProxyOptions } from "../src/server.ts"; import { SessionStore, _setStoreForTest } from "../src/persist.ts"; import { _setForTest as setRegistryForTest } from "../src/registry.ts"; +import { _resetSessionsForTest } from "../src/session.ts"; import { _resetPluginStateForTest, handlePluginRuntimeInfo, @@ -148,6 +152,11 @@ interface Harness { } async function startHarness(): Promise { + // isolate the state dir: prefix-affinity hydration reattaches anonymous + // sessions from disk, which would defeat the "no session yet" preconditions + const stateHome = fs.mkdtempSync(path.join(os.tmpdir(), "bili-ri-state-")); + const prevStateHome = process.env.XDG_STATE_HOME; + process.env.XDG_STATE_HOME = stateHome; const upstream = http.createServer((req, res) => { req.resume(); req.on("end", () => { @@ -162,6 +171,7 @@ async function startHarness(): Promise { _setStoreForTest(new SessionStore({ enabled: false })); setRegistryForTest({}); _resetPluginStateForTest(); + _resetSessionsForTest(); const proxy = await startServer({ port: 0, host: "127.0.0.1", @@ -185,6 +195,9 @@ async function startHarness(): Promise { proxy.close(); upstream.close(); await Promise.allSettled([once(proxy, "close"), once(upstream, "close")]); + if (prevStateHome === undefined) delete process.env.XDG_STATE_HOME; + else process.env.XDG_STATE_HOME = prevStateHome; + fs.rmSync(stateHome, { recursive: true, force: true }); }, }; } @@ -252,3 +265,63 @@ describe("runtime-info in the native-window chain (#955, e2e)", () => { assert.notEqual(status.windowSource, "runtime-info"); }); }); + +// ---- E2E: pre-first-request /acp served from the runtime table ---- + +describe("runtime-info pre-first-request status (#955)", () => { + let h: Harness | undefined; + beforeEach(async () => { + h = await startHarness(); + }); + afterEach(async () => { + await h?.close(); + h = undefined; + }); + + it("answers from the agent-keyed table before any session exists", async () => { + const report = await fetch(`http://127.0.0.1:${h!.proxyPort}/__bili/plugin/runtime-info`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ agent: "dsh", model: "test-model", contextWindow: 262144, maxOutput: 32768, source: "client-config" }), + }); + assert.equal(report.status, 200); + + // clients without a stable conversation id probe with their agent name + // (fetchStatusLatest hardcodes conversationId=dsh&fallback=latest) + const resp = await fetch(`http://127.0.0.1:${h!.proxyPort}/__bili/plugin/status?conversationId=dsh&fallback=latest`); + assert.equal(resp.status, 200); + const json = (await resp.json()) as { ok: boolean; phase?: string; model: string | null; contextLimit: number | null; runtimeInfo: { maxOutput?: number } | null; panel: unknown }; + assert.equal(json.ok, true); + assert.equal(json.phase, "pre-first-request"); + assert.equal(json.model, "test-model"); + assert.equal(json.contextLimit, 262144); + assert.equal(json.runtimeInfo?.maxOutput, 32768); + assert.equal(json.panel, null); + }); + + it("still 404s when nothing was reported", async () => { + const resp = await fetch(`http://127.0.0.1:${h!.proxyPort}/__bili/plugin/status?conversationId=dsh&fallback=latest`); + assert.equal(resp.status, 404); + const json = (await resp.json()) as { error: string }; + assert.match(json.error, /no session with activity since boot/); + }); + + it("yields to the real session once a model request lands", async () => { + await fetch(`http://127.0.0.1:${h!.proxyPort}/__bili/plugin/runtime-info`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ agent: "dsh", model: "test-model", contextWindow: 262144, source: "client-config" }), + }); + const req = await fetch(`http://127.0.0.1:${h!.proxyPort}/bili/http://127.0.0.1:${h!.upstreamPort}/v1/chat/completions`, { + method: "POST", + headers: { "content-type": "application/json", "x-bili-plugin": "dsh", "x-bili-plugin-conversation": "conv-pre-1" }, + body: JSON.stringify({ model: "test-model", stream: false, messages: [{ role: "user", content: "hello" }] }), + }); + assert.equal(req.status, 200); + const resp = await fetch(`http://127.0.0.1:${h!.proxyPort}/__bili/plugin/status?conversationId=dsh&fallback=latest`); + assert.equal(resp.status, 200); + const json = (await resp.json()) as { phase?: string; conversationId: string }; + assert.equal(json.phase, undefined); + assert.equal(json.conversationId, "conv-pre-1"); + }); +}); From bbef7491e51f6c5dfecb0c86a889837414d58671 Mon Sep 17 00:00:00 2001 From: Sisyphus Date: Sat, 19 Sep 2026 08:44:25 +0800 Subject: [PATCH 3/4] =?UTF-8?q?fix:=20harden=20runtime-info=20=E2=80=94=20?= =?UTF-8?q?dsh=20mid-switch=20race=20guard=20+=20model=20cross-check=20on?= =?UTF-8?q?=20plugin=20headers?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review findings on PR #956: - dsh-native refreshModelInfo: an in-flight resolve whose model selection changed mid-flight must not overwrite the cache or report the OLD model (one-turn transient mis-sizing); commit + report are now gated on the live selection still matching what was resolved. - server: per-request x-bili-plugin-context-window / -max-output headers are now ignored when x-bili-plugin-model differs from the request body's model (exact or last path segment — openai bodies use provider/model composites). A missing model header keeps the pre-#956 trust. --- src/agent/dsh-native.ts | 24 ++++++++++++++++++++---- src/plugin.ts | 14 ++++++++++++++ src/server.ts | 6 +++--- tests/runtime-info.test.ts | 13 +++++++++++++ 4 files changed, 50 insertions(+), 7 deletions(-) diff --git a/src/agent/dsh-native.ts b/src/agent/dsh-native.ts index 7f7358e2..0d2f66c8 100644 --- a/src/agent/dsh-native.ts +++ b/src/agent/dsh-native.ts @@ -101,6 +101,15 @@ const register: RegisterState = { base: undefined, toolsReady: false, dead: fals type ModelInfoCache = { provider: string; model: string; contextWindow?: number; maxOutput?: number }; const modelInfo: { cached?: ModelInfoCache; services?: { llm?: PluginContext["llm"]; agentDefaultModel?: PluginContext["agentDefaultModel"] }; refreshing: boolean } = { refreshing: false }; +function selectionStillCurrent(svc: { agentDefaultModel?: PluginContext["agentDefaultModel"] }, provider: string, model: string): boolean { + try { + const live = svc.agentDefaultModel?.currentSelection?.(); + return live?.provider === provider && live?.model === model; + } catch { + return false; + } +} + function refreshModelInfo(origin: string | undefined): void { const svc = modelInfo.services; if (svc === undefined || modelInfo.refreshing) return; @@ -123,6 +132,11 @@ function refreshModelInfo(origin: string | undefined): void { void Promise.resolve() .then(() => resolve(provider, model)) .then((info) => { + // Commit only if the LIVE selection still matches what we + // resolved: a model switch mid-resolve must not overwrite the + // cache (and report) the OLD model's numbers — the next + // headersFor refresh re-resolves the new one (review on #956). + if (!selectionStillCurrent(svc, provider, model)) return; modelInfo.cached = { provider, model, @@ -131,18 +145,20 @@ function refreshModelInfo(origin: string | undefined): void { }; }) .catch(() => { + if (!selectionStillCurrent(svc, provider, model)) return; // Resolution failed (transient catalog read, model offline): keep // the model id (usable for registry lookup) without window claims. modelInfo.cached = { provider, model }; }) .finally(() => { modelInfo.refreshing = false; - if (origin !== undefined && modelInfo.cached !== undefined) { + const cached = modelInfo.cached; + if (cached !== undefined && cached.provider === provider && cached.model === model && origin !== undefined) { void reportRuntimeInfo(origin, { agent: "dsh", - model: modelInfo.cached.model, - contextWindow: modelInfo.cached.contextWindow, - maxOutput: modelInfo.cached.maxOutput, + model: cached.model, + contextWindow: cached.contextWindow, + maxOutput: cached.maxOutput, source: "client-config", }).catch(() => {}); } diff --git a/src/plugin.ts b/src/plugin.ts index 2aa0241b..61535cc4 100644 --- a/src/plugin.ts +++ b/src/plugin.ts @@ -130,6 +130,20 @@ export function pluginReportedModel(headers: Record, bodyModel: string | undefined): boolean { + const reported = pluginReportedModel(headers); + if (reported === undefined || bodyModel === undefined) return true; + if (reported === bodyModel) return true; + return bodyModel.split("/").pop() === reported; +} + type ConversationEntry = { sessionId: string; lastSeen: number }; type RememberedMessages = { processed: CoreMessage[]; original: CoreMessage[]; nudge?: NudgeDecision }; diff --git a/src/server.ts b/src/server.ts index 0a46dc1c..91473ba5 100644 --- a/src/server.ts +++ b/src/server.ts @@ -77,7 +77,7 @@ import { emitPreflightError, emitStreamError } from "./stream-error.js"; import { affinityToken, clientConversationHeader, codexTurnIdentity, preferPromptCacheKeyIdentity, type ConversationIdentity } from "./session-id.js"; import { prefixAffinity, type AnonymousAffinity } from "./prefix-affinity.js"; import { flushPrefixAffinity, hydratePrefixAffinity, scheduleAffinityPersist } from "./affinity-persist.js"; -import { consumePluginRegisterFor, flushConversations, handlePluginCompact, handlePluginManifest, handlePluginRegister, handlePluginRuntimeInfo, handlePluginStatus, handlePluginTool, loadConversations, pipePluginChatWithStrip, pipePluginJson, pipePluginResponsesWithStrip, pluginAgentHeader, pluginConversationHeader, pluginReportedContextWindow, pluginReportedMaxOutput, pluginRuntimeInfoFor, recordPluginSession, rememberPluginMessages, takePendingPluginRegister } from "./plugin.js"; +import { consumePluginRegisterFor, flushConversations, handlePluginCompact, handlePluginManifest, handlePluginRegister, handlePluginRuntimeInfo, handlePluginStatus, handlePluginTool, loadConversations, pipePluginChatWithStrip, pipePluginJson, pipePluginResponsesWithStrip, pluginAgentHeader, pluginConversationHeader, pluginHeadersMatchModel, pluginReportedContextWindow, pluginReportedMaxOutput, pluginRuntimeInfoFor, recordPluginSession, rememberPluginMessages, takePendingPluginRegister } from "./plugin.js"; import { setupMitm, readMitmUpstream, getBlindTunnelStats } from "./mitm.js"; import type { BiliMessage } from "acp-kernel/wire"; import { BILI_PLUGIN_BYPASS_HEADER, hardenOpenaiAssistantContent, isLoopbackAddress, inspectContextOverflow, reserveOutputHeadroom, resolveOutputHeadroomCap, shouldReserveOutputHeadroom, systemToUser, usageTotals, type WireProtocol } from "./util.js"; @@ -1012,7 +1012,7 @@ async function handle( // outranks everything inside resolveRequestConfig. const host = (() => { try { return embeddedUrl ? new URL(embeddedUrl).host : undefined; } catch { return undefined; } })(); const betaWindow = anthropicBetaContextWindow(req.headers); - const pluginWindow = pluginReportedContextWindow(req.headers); + const pluginWindow = pluginHeadersMatchModel(req.headers, model) ? pluginReportedContextWindow(req.headers) : undefined; // Runtime-table fallback for the window (#955): only when this // request's plugin sent no window header AND the agent's latest // runtime-info entry matches THIS request's model — a stale @@ -1483,7 +1483,7 @@ async function handle( // actually ask the upstream for. Still only a fallback: a // max_tokens on the wire beat it above. const fbModel0 = (parsed as { model?: string }).model; - const runtimeMax = pluginReportedMaxOutput(req.headers) ?? pluginRuntimeInfoFor(pluginAgentHeader(req.headers), fbModel0)?.maxOutput; + const runtimeMax = (pluginHeadersMatchModel(req.headers, fbModel0) ? pluginReportedMaxOutput(req.headers) : undefined) ?? pluginRuntimeInfoFor(pluginAgentHeader(req.headers), fbModel0)?.maxOutput; if (typeof runtimeMax === "number" && runtimeMax > 0) { maxOutput = runtimeMax; if (!headroomFallbackLogged.has(`${fbModel0 ?? "?"}|runtime-info`)) { diff --git a/tests/runtime-info.test.ts b/tests/runtime-info.test.ts index 424354bd..a42f12ad 100644 --- a/tests/runtime-info.test.ts +++ b/tests/runtime-info.test.ts @@ -18,6 +18,7 @@ import { _resetSessionsForTest } from "../src/session.ts"; import { _resetPluginStateForTest, handlePluginRuntimeInfo, + pluginHeadersMatchModel, pluginReportedMaxOutput, pluginReportedModel, pluginRuntimeInfoFor, @@ -51,6 +52,18 @@ describe("runtime-info header parsing (#955)", () => { assert.equal(pluginReportedModel({ "x-bili-plugin-model": "qwen" }), undefined); assert.equal(pluginReportedModel({ "x-bili-plugin": "pi", "x-bili-plugin-model": "a b" }), undefined); }); + + it("header/model cross-check: a different body model rejects the plugin headers (#956 hardening)", () => { + const h = { "x-bili-plugin": "pi", "x-bili-plugin-model": "qwen-a" }; + assert.equal(pluginHeadersMatchModel(h, "qwen-a"), true); + assert.equal(pluginHeadersMatchModel(h, "qwen-b"), false); + // provider/model composite bodies match the bare stamped id + assert.equal(pluginHeadersMatchModel(h, "sglang/qwen-a"), true); + assert.equal(pluginHeadersMatchModel(h, "sglang/qwen-b"), false); + // no model header = pre-#956 trust preserved + assert.equal(pluginHeadersMatchModel({ "x-bili-plugin": "pi" }, "qwen-b"), true); + assert.equal(pluginHeadersMatchModel(h, undefined), true); + }); }); describe("runtime-info endpoint handler (#955)", () => { From 3f3e5e948a0458170c55d456aba546c3ea9b571e Mon Sep 17 00:00:00 2001 From: ework-agent Date: Sat, 19 Sep 2026 08:53:03 +0800 Subject: [PATCH 4/4] test(#955): regression for the dsh mid-resolve model-switch guard (#956 review) The selectionStillCurrent gate in refreshModelInfo had no dedicated test: drive apply() with a gated async resolve of model A, switch the live selection to B mid-resolve, assert A's numbers never land in the cache or headers, then assert the next refresh self-heals by re-resolving B. --- tests/dsh-native.test.ts | 46 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 46 insertions(+) diff --git a/tests/dsh-native.test.ts b/tests/dsh-native.test.ts index d4345ceb..c02f9344 100644 --- a/tests/dsh-native.test.ts +++ b/tests/dsh-native.test.ts @@ -469,6 +469,52 @@ test("apply() runtime-info (#955): model services stamp model/window/max-output } }); +test("apply() runtime-info (#956): a mid-resolve model switch discards the stale resolve", async () => { + const proxy = await startMockProxy([]); + const home = fs.mkdtempSync(path.join(os.tmpdir(), "bili-dsh-race-")); + try { + await withEnv({ DSH_HOME: home, BILLION_CONTEXT_PROXY: proxy.origin }, async () => { + _resetRegisterForTest(proxy.origin); + const ctx = mockCtx(); + // mutable live selection: A at startup, switched to B mid-resolve + let selection = { provider: "deepseek", model: "qwen-a" }; + type ModelInfoLike = { context?: { contextWindow?: number }; defaultMaxTokens?: number }; + let releaseA: ((v: ModelInfoLike) => void) | undefined; + const gateA = new Promise((r) => { + releaseA = r; + }); + ctx.setModelServices( + { + resolveModelInfo: async (_provider, model) => + model === "qwen-a" ? gateA : { context: { contextWindow: 12345 }, defaultMaxTokens: 4096 }, + }, + { currentSelection: () => selection }, + ); + apply(ctx); + await waitFor(() => ctx.registeredTools.length === 1, "manifest tool registration (race)"); + ctx.setInitiator({ session: { id: "session-race" } }); + const stamp = () => _stateHeadersForTest()?.("http://example.test/v1/chat/completions"); + // apply()'s inject already started A's async resolve (gated, in flight) + assert.equal(stamp()?.["x-bili-plugin-context-window"], undefined); + // switch the LIVE selection to B while A is still resolving + selection = { provider: "deepseek", model: "qwen-b" }; + releaseA?.({ context: { contextWindow: 999999 }, defaultMaxTokens: 8888 }); + await new Promise((r) => setTimeout(r, 20)); + // the stale A result must NOT have been committed or stamped + assert.equal(stamp()?.["x-bili-plugin-context-window"], undefined); + assert.notEqual(stamp()?.["x-bili-plugin-model"], "qwen-a"); + // self-heal: the next refresh re-resolves the LIVE selection (B) + await waitFor(() => stamp()?.["x-bili-plugin-context-window"] === "12345", "post-switch re-resolve stamped B"); + assert.equal(stamp()?.["x-bili-plugin-model"], "qwen-b"); + assert.equal(stamp()?.["x-bili-plugin-max-output"], "4096"); + }); + } finally { + proxy.close(); + fs.rmSync(home, { recursive: true, force: true }); + _resetRegisterForTest(undefined); + } +}); + test("apply() /acp pre-first-request (#955): renders the runtime-table entry before any model request", async () => { const pre = { ok: true,