Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 31 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -207,8 +207,37 @@ 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 `<proxy>/bili/<upstream-url>`, 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.

Before the first model request there is no session yet, so the `/acp` panel
probes `GET /__bili/plugin/status?conversationId=<agent>&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:

Expand Down
15 changes: 14 additions & 1 deletion README.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -125,7 +125,20 @@ bili plugin remove <client> # 卸载(dsh 还原占位符;配置快照存 .bi

dsh 用户可以完全不用 bili:`dsh plugin --profile <name> add billion-context` 经 dsh 自己的插件通道装上同一个原生插件(pnpm 装进 profile、挂载包内 patch 层)—— 见下文 dsh 段。

插件加载时**自拉起自己的代理**(已有健康实例则直接复用 —— 父进程 pid 看门狗在客户端退出时收掉它),把模型流量改写到 `<proxy>/bili/<上游URL>`,并把 `compress` / `decompress` / `acp_status` 注册为客户端原生工具(plugin 模式),`/acp` 面板绑定当前会话。退出开关:`BILI_NATIVE_PI=0`、`BILI_NATIVE_OPENCODE=0`、`BILI_NATIVE_DSH=0`。
插件加载时**自拉起自己的代理**(已有健康实例则直接复用 —— 父进程 pid 看门狗在客户端退出时收掉它),把模型流量改写到 `<proxy>/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` —— 其他客户端接入请遵循同一协议。

首次模型请求之前会话尚不存在,`/acp` 面板会探测 `GET /__bili/plugin/status?conversationId=<agent>&fallback=latest`,代理从 runtime-info 表应答(`phase: "pre-first-request"`)而不是返回 404 —— 上报的配置立即可见,流量落地后由真实会话接管。

注意:

Expand Down
126 changes: 124 additions & 2 deletions src/agent/dsh-native.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"];
Expand All @@ -57,6 +57,12 @@ type PluginContext = {
tools: { register: (definition: ToolDefinition) => unknown };
commands: { register: (command: { name: string; description: string; handler: () => Promise<CommandOutcome> }) => 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. */
Expand Down Expand Up @@ -87,6 +93,78 @@ 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 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;
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) => {
// 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,
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(() => {
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;
const cached = modelInfo.cached;
if (cached !== undefined && cached.provider === provider && cached.model === model && origin !== undefined) {
void reportRuntimeInfo(origin, {
agent: "dsh",
model: cached.model,
contextWindow: cached.contextWindow,
maxOutput: cached.maxOutput,
source: "client-config",
}).catch(() => {});
}
});
}

function errMessage(err: unknown): string {
return err instanceof Error ? err.message : String(err);
}
Expand Down Expand Up @@ -184,6 +262,21 @@ async function statusOutcome(ctx: PluginContext): Promise<CommandOutcome> {
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 {
Expand Down Expand Up @@ -224,13 +317,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<string, string> = { "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",
Expand All @@ -250,6 +369,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<string, string> | undefined) | undefined {
Expand Down
28 changes: 27 additions & 1 deletion src/agent/opencode-native.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down Expand Up @@ -427,6 +428,24 @@ export function extractV1Windows(cfg: V1Config): Map<string, number> {
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<string, number> {
const map = new Map<string, number>();
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;
Expand Down Expand Up @@ -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<string, number>();
let outputs = new Map<string, number>();
const hooks: V1Hooks = {
config: async (cfg) => {
if (legacy?.configHook !== undefined) {
Expand All @@ -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)) {
Expand Down Expand Up @@ -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)));
Expand Down
Loading
Loading