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
17 changes: 17 additions & 0 deletions plugins/provider-pi/src/bridge/bb-pi-extension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -245,6 +245,8 @@ export default function bbExtension(pi) {
}
case "leaf":
return { leafId: currentLeafId() };
case "model-scope":
return currentModelScope();
default:
throw new Error("unknown bridge request " + String(message.method));
}
Expand All @@ -254,6 +256,17 @@ export default function bbExtension(pi) {
return sessionContext?.sessionManager?.getLeafId?.() ?? null;
}

function currentModelScope() {
return {
scopedModelIds: (sessionContext?.scopedModels ?? []).map(
({ model }) => model.provider + "/" + model.id,
),
defaultModelId: sessionContext?.model
? sessionContext.model.provider + "/" + sessionContext.model.id
: null,
};
}

for (const tool of tools) {
pi.registerTool({
name: tool.name,
Expand Down Expand Up @@ -291,6 +304,10 @@ export default function bbExtension(pi) {

pi.on("session_start", async (_event, ctx) => {
sessionContext = ctx;
writeLine(CHILD_TO_BRIDGE_FD, {
kind: "model-scope",
...currentModelScope(),
});
// Pi's active-tool set is session state; a resumed or forked session can
// predate the bb tools, so make sure every injected tool is active.
if (tools.length > 0 && typeof pi.setActiveTools === "function") {
Expand Down
54 changes: 54 additions & 0 deletions plugins/provider-pi/src/bridge/catalog.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
import { BB_PI_EXTENSION_SOURCE } from "./bb-pi-extension.js";
import { closeAllPiCatalogs, getPiCatalog } from "./catalog.js";
import { PI_BRIDGE_ARGS_ENV, PI_BRIDGE_COMMAND_ENV } from "./rpc-child.js";
import { fakePiPath } from "./test-support.js";

const originalEnv = { ...process.env };
const tempDirs: string[] = [];

afterEach(async () => {
await closeAllPiCatalogs();
process.env = { ...originalEnv };
for (const dir of tempDirs.splice(0)) {
rmSync(dir, { recursive: true, force: true });
}
});

describe("pi catalog child generations", () => {
it("re-reads model scope after the catalog child restarts", async () => {
const workspace = mkdtempSync(join(tmpdir(), "bb-pi-catalog-"));
tempDirs.push(workspace);
const extensionPath = join(workspace, "bb-extension.mjs");
const spawnCounterPath = join(workspace, "spawns.txt");
const processLogPath = join(workspace, "processes.txt");
writeFileSync(extensionPath, BB_PI_EXTENSION_SOURCE);

process.env[PI_BRIDGE_COMMAND_ENV] = process.execPath;
process.env[PI_BRIDGE_ARGS_ENV] = JSON.stringify([fakePiPath]);
process.env.FAKE_PI_SPAWN_COUNTER_FILE = spawnCounterPath;
process.env.FAKE_PI_PROCESS_LOG = processLogPath;
process.env.FAKE_PI_SCOPE_BY_SPAWN = "1";
process.env.FAKE_PI_EXIT_AFTER_FIRST_AVAILABLE = "1";

const catalog = await getPiCatalog(workspace, extensionPath);
const first = await catalog.listModels();
expect(first.models.map((model) => model.id)).toEqual([
"fake-provider/fake-model",
]);

await vi.waitFor(() => {
expect(readFileSync(processLogPath, "utf8")).toContain("exit:");
});

const second = await catalog.listModels();
expect(second.models.map((model) => model.id)).toEqual([
"fake-provider/fake-mini",
]);
expect(second.models[0]?.isDefault).toBe(true);
expect(readFileSync(spawnCounterPath, "utf8")).toBe("2");
});
});
130 changes: 108 additions & 22 deletions plugins/provider-pi/src/bridge/catalog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,11 @@ import {
type PiModelContextWindowResolver,
} from "../delta-translation.js";
import { buildPiAvailableModels, type PiCatalogModel } from "../model-list.js";
import { PiRpcChild, buildPiChildEnv } from "./rpc-child.js";
import {
PiRpcChild,
PiRpcChildExitedError,
buildPiChildEnv,
} from "./rpc-child.js";

const EXTENDED_THINKING_LEVELS = [
"off",
Expand Down Expand Up @@ -85,24 +89,90 @@ async function spawnCatalog(
extensionPath: string,
touch: () => void,
): Promise<PiCatalog> {
let child: PiRpcChild | null = null;
const spawnChild = (): PiRpcChild => {
if (child !== null && !child.exited) {
return child;
}
child = new PiRpcChild({
interface CatalogChildGeneration {
child: PiRpcChild;
ready: Promise<Record<string, unknown>>;
getModelScope():
| { scopedModelIds: string[]; defaultModelId?: string }
| undefined;
}

let generation: CatalogChildGeneration | null = null;
const spawnGeneration = (): CatalogChildGeneration => {
let modelScope:
| { scopedModelIds: string[]; defaultModelId?: string }
| undefined;
let settleModelScopeRequest: (() => void) | undefined;
const acceptModelScope = (value: Record<string, unknown>): void => {
const scopedModelIds = Array.isArray(value.scopedModelIds)
? value.scopedModelIds.filter(
(id): id is string => typeof id === "string",
)
: [];
modelScope = {
scopedModelIds,
defaultModelId:
typeof value.defaultModelId === "string"
? value.defaultModelId
: undefined,
};
};
const child = new PiRpcChild({
cwd,
env: buildPiChildEnv({}),
args: ["--mode", "rpc", "--no-session", "--extension", extensionPath],
onEvent: () => {},
onChannelMessage: () => {},
onChannelMessage: (message) => {
if (message.kind === "model-scope") {
acceptModelScope(message);
return;
}
if (
message.kind === "reply" &&
message.id === "catalog-model-scope" &&
typeof message.result === "object" &&
message.result !== null
) {
acceptModelScope(message.result as Record<string, unknown>);
settleModelScopeRequest?.();
settleModelScopeRequest = undefined;
}
},
onExit: () => {},
recordThreadId: null,
});
return child;
const ready = (async (): Promise<Record<string, unknown>> => {
const data = await child.requestOk({ type: "get_state" });
await new Promise<void>((resolveScope) => {
const timeout = setTimeout(resolveScope, 2_000);
timeout.unref?.();
settleModelScopeRequest = () => {
clearTimeout(timeout);
resolveScope();
};
child.sendChannel({
kind: "request",
id: "catalog-model-scope",
method: "model-scope",
});
});
return typeof data === "object" && data !== null
? (data as Record<string, unknown>)
: {};
})();
return { child, ready, getModelScope: () => modelScope };
};
const fetchRaw = async (): Promise<PiRpcModel[]> => {
const data = (await spawnChild().requestOk({
const activeGeneration = (): CatalogChildGeneration => {
if (generation === null || generation.child.exited) {
generation = spawnGeneration();
}
return generation;
};
const fetchRawFrom = async (
active: CatalogChildGeneration,
): Promise<PiRpcModel[]> => {
await active.ready;
const data = (await active.child.requestOk({
type: "get_available_models",
})) as { models?: unknown[] } | undefined;
touch();
Expand All @@ -111,16 +181,29 @@ async function spawnCatalog(
typeof entry === "object" && entry !== null,
);
};
const probe = async (): Promise<Record<string, unknown>> => {
const data = await spawnChild().requestOk({ type: "get_state" });
return typeof data === "object" && data !== null
? (data as Record<string, unknown>)
: {};
const fetchGeneration = async (): Promise<{
active: CatalogChildGeneration;
raw: PiRpcModel[];
}> => {
const first = activeGeneration();
try {
return { active: first, raw: await fetchRawFrom(first) };
} catch (error) {
if (!(error instanceof PiRpcChildExitedError)) {
throw error;
}
const active = activeGeneration();
return { active, raw: await fetchRawFrom(active) };
}
};
const fetchRaw = async (): Promise<PiRpcModel[]> =>
(await fetchGeneration()).raw;
const probe = async (): Promise<Record<string, unknown>> =>
activeGeneration().ready;
await probe();
return {
async listModels() {
const raw = await fetchRaw();
const { active, raw } = await fetchGeneration();
const models: PiCatalogModel[] = [];
for (const model of raw) {
const catalogModel = toCatalogModel(model);
Expand All @@ -132,15 +215,18 @@ async function spawnCatalog(
);
}
}
return buildPiAvailableModels({ models });
const modelScope = active.getModelScope();
return buildPiAvailableModels({
models,
scopedModelIds: modelScope?.scopedModelIds,
preferredDefaultId: modelScope?.defaultModelId,
});
},
rawModels: fetchRaw,
probe,
async close() {
const activeChild = child;
if (activeChild === null) {
return;
}
const activeChild = generation?.child;
if (activeChild === undefined) return;
activeChild.kill();
await activeChild.waitForExit();
},
Expand Down
12 changes: 12 additions & 0 deletions plugins/provider-pi/src/bridge/fake-pi-rpc.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -228,9 +228,15 @@ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
const extensionTools = new Map();
const extensionHandlers = new Map();
let activeTools = ["read", "bash", "edit", "write"];
const scopedModel =
process.env.FAKE_PI_SCOPE_BY_SPAWN === "1"
? MODELS[spawnIndex === 1 ? 0 : 1]
: undefined;
const extensionContext = {
cwd: process.cwd(),
sessionManager: { getLeafId: () => leafId },
model: scopedModel,
scopedModels: scopedModel ? [{ model: scopedModel }] : [],
};

async function emitExtensionEvent(type, payload = {}) {
Expand Down Expand Up @@ -432,6 +438,12 @@ async function handle(command) {
return;
case "get_available_models":
respond(id, "get_available_models", { models: MODELS });
if (
process.env.FAKE_PI_EXIT_AFTER_FIRST_AVAILABLE === "1" &&
spawnIndex === 1
) {
setTimeout(exit, 25);
}
return;
case "set_model": {
const found = MODELS.find(
Expand Down
91 changes: 91 additions & 0 deletions plugins/provider-pi/src/model-list.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,97 @@ describe("pi model list", () => {
);
});

it("restricts and orders the picker using Pi's workspace model scope", () => {
const { models, selectedOnlyModels } = buildPiAvailableModels({
models: [
{
id: "claude-sonnet-5",
name: "Claude Sonnet 5",
provider: "anthropic",
reasoning: true,
input: ["text"],
supportedThinkingLevels: ["low", "medium", "high"],
},
{
id: "gpt-5.4",
name: "GPT-5.4",
provider: "openai",
reasoning: true,
input: ["text"],
supportedThinkingLevels: ["low", "medium", "high"],
},
],
scopedModelIds: ["openai/gpt-5.4", "anthropic/claude-sonnet-5"],
preferredDefaultId: "openai/gpt-5.4",
});

expect(models.map((model) => model.id)).toEqual([
"openai/gpt-5.4",
"anthropic/claude-sonnet-5",
]);
expect(models.find((model) => model.isDefault)?.id).toBe("openai/gpt-5.4");
expect(selectedOnlyModels).toHaveLength(0);
});

it("inherits Pi's saved default when no scope is configured", () => {
const { models } = buildPiAvailableModels({
models: [
{
id: "claude-sonnet-5",
name: "Claude Sonnet 5",
provider: "anthropic",
reasoning: true,
input: ["text"],
supportedThinkingLevels: ["low", "medium", "high"],
},
{
id: "gpt-5.4",
name: "GPT-5.4",
provider: "openai",
reasoning: true,
input: ["text"],
supportedThinkingLevels: ["low", "medium", "high"],
},
],
preferredDefaultId: "anthropic/claude-sonnet-5",
});

expect(models.find((model) => model.isDefault)?.id).toBe(
"anthropic/claude-sonnet-5",
);
});

it("keeps a dated model explicitly included by Pi's scope", () => {
const { models, selectedOnlyModels } = buildPiAvailableModels({
models: [
{
id: "claude-opus-4-8",
name: "Claude Opus 4.8",
provider: "anthropic",
reasoning: true,
input: ["text"],
supportedThinkingLevels: ["low", "medium", "high"],
},
{
id: "claude-opus-4-8-20260115",
name: "Claude Opus 4.8 (2026-01-15)",
provider: "anthropic",
reasoning: true,
input: ["text"],
supportedThinkingLevels: ["low", "medium", "high"],
},
],
scopedModelIds: ["anthropic/claude-opus-4-8-20260115"],
preferredDefaultId: "anthropic/claude-opus-4-8-20260115",
});

expect(models.map((model) => model.id)).toEqual([
"anthropic/claude-opus-4-8-20260115",
]);
expect(models[0]?.isDefault).toBe(true);
expect(selectedOnlyModels).toHaveLength(0);
});

it("reads the context window of the provider that served the message", () => {
const resolveContextWindow = createPiModelContextWindowResolverFrom([
{
Expand Down
Loading