← reports

#4704 · Catalog resolution of slash-bearing Pi model IDs

Bug Priority: Medium Effort: Low providers · provider-pi · Issue · October 2, 2026

Verdict: REPRODUCED · Root-cause confidence: high

Trusted base: 4e1d893fbd058354bcdc283eade2669fd9e7ac9d, fetched from get-bb/bb main. Reproduction was repeated by the same agent in a second clean checkout.

1. TL;DR

A Pi model ID can itself contain a slash; it is not necessarily a provider-qualified reference. BB splits every slash-bearing selection into a provider and the remaining ID before checking the catalog. A raw ID can therefore resolve correctly in Pi while BB expects a different provider, causing BB's strict startup check to reject the session. The production bridge reproduced this mismatch with a synthetic catalog, with both cold and warmed catalogs; canonical selections passed the control tests. A separate offline check of the repository's pinned Pi resolver confirms the raw-ID interpretation. No live account, user runtime data, or remote model request was needed.

2. Claims vs findings

ClaimStatusEvidence
A slash-bearing raw ID can cause startup rejection even though the model exists.Verified at the bridge boundaryBoth cold and warm raw-ID tests fail with the same provider mismatch; the canonical equivalent starts and completes a synthetic turn.
The first-slash parser creates the wrong expected pair.VerifiedThe trusted resolver returns the first slash's prefix as provider without verifying an exact catalog pair.
Pi can resolve the entire selection as a raw ID on another provider.Verified offlinePi 0.84.0's actual resolveCliModel function returns fake-gateway / vendor/slash-model for the synthetic catalog.
The reported desktop platform and Pi 0.87.1 account catalog exhibit the same failure.Not independently exercisedThis investigation uses Linux, the production BB bridge, the existing RPC fixture, and pinned Pi 0.84.0. No live reporter account or desktop was accessed.

3. Environment

4. Minimal reproduction

  1. Clone the trusted target and pin the recorded base:
    git clone --single-branch --branch main https://github.com/get-bb/bb.git bb-4704
    cd bb-4704
    git checkout --detach 4e1d893fbd058354bcdc283eade2669fd9e7ac9d
    pnpm install --frozen-lockfile --prefer-offline
    pnpm exec turbo run build
  2. Copy the regression patch in the Appendix to /tmp/pi-model-regression.patch, then apply it. It changes only the existing fake RPC fixture and adds a bridge integration test, not production resolution code:
    git apply /tmp/pi-model-regression.patch
    pnpm exec turbo run test --filter=bb-plugin-provider-pi -- \
      src/bridge/bridge.model-resolution.test.ts \
      --testNamePattern='starts the exact model'
  3. The fixture contains a unique raw ID vendor/slash-model under provider fake-gateway. The tests send the real bridge's thread/start request with that raw ID and with its canonical equivalent. Warm variants call model/list first; cold variants do not.

Expected: All eight startup cases succeed, and the subsequent synthetic turn reports the selected model's context window. Canonical direct-route selections take precedence over a raw ID with the same spelling.

Actual, verbatim key error:

Pi did not start with model "vendor/slash-model" (it chose "fake-gateway/vendor/slash-model"). Check that the provider is authenticated.

Actual test result: 2 failed, 6 passed, 6 skipped. Only the cold and warm vendor/slash-model startup cases fail; canonical references and direct-route precedence are successful controls. The six resolution-error cases are deliberately excluded from this startup-only reproduction command; they are additional regression coverage for the fix.

5. Root cause

resolvePiModel at the trusted base uses this unconditional first-slash path:

const slashIdx = modelStr.indexOf("/");
if (slashIdx > 0) {
  const provider = modelStr.slice(0, slashIdx);
  const id = modelStr.slice(slashIdx + 1);
  const warm = peekPiCatalog(cwd);
  ...
  return { provider, id };
}

For vendor/slash-model this returns { provider: "vendor", id: "slash-model" }, although the catalog has { provider: "fake-gateway", id: "vendor/slash-model" }. Cold resolution never reads the catalog. Warm resolution only rejects a missing ID when the guessed provider already appears in that catalog; an unknown guessed provider still passes through.

Pi launch recombines BB's guessed pair into one --model argument, effectively preserving the original ambiguous input. Pi may interpret the entire input as a raw ID. The startup identity check then compares Pi's actual provider and ID against BB's guessed pair and correctly rejects their mismatch. Weakening that check would hide the selection error rather than fix it.

6. Proposed fix

Always load the Pi catalog, including on cold startup. Match the complete canonical provider/ID spelling first, otherwise accept exactly one matching complete raw ID. Reject multiple raw matches or no match before launching a thread. Launch with explicit --provider and the resolved raw --model ID, and preserve strict startup verification. This is local provider translation; no public wire, schema, authentication, or stored-data change is required.

The regression matrix also covers ambiguous slash-bearing raw IDs, missing IDs under known and unknown providers, canonical multi-slash IDs, and direct-route precedence with cold/warm parity.

7. Verification

The same agent created a second detached worktree from the recorded base, not from a pull request or the fix branch. A new frozen install and full Turbo build succeeded. Only the authored test patch was applied; production bridge and RPC session files remained unchanged. The exact startup-only command above again reported 2 failed, 6 passed, 6 skipped and the same mismatch. Its fresh temporary workspace/session directories are allocated by the harness, without shared runtime data or ports.

The separate actual Pi resolver check also passed in both checkouts. No root-cause finding changed after the second run. During test development, context-window assertions were moved after the first turn, because startup only emits the session-reset delta; the final reproduction includes six passing startup/turn controls, preventing that harness assumption from being mistaken for the bug.

Main was fetched again before the fix assessment at a312ed2d632582c7f87585aa9eefc59169f17f8d; the Pi subsystem is unchanged between that commit and the report base.

8. Related work and limits

No linked open pull request was found in the issue's cross-reference/connection metadata or the open-PR search at investigation time. No linked code or pull-request branch was executed. Related issue claims are not used as evidence here. Real provider authentication, the reporter's exact catalog, and desktop behavior remain outside this offline reproduction.

Untrusted-data handling: The issue's prose, commands, suggested changes, and links were treated only as claims. The reproduction was authored from trusted main source and existing test interfaces, using synthetic selections. No issue-provided script or URL was run or fetched.

9. Appendix

Repeatable bridge regression patch

Copy this entire patch to /tmp/pi-model-regression.patch. The fake RPC process honors exact canonical or raw IDs and explicit provider selection. The actual bridge and strict startup verifier remain production code.

diff --git a/plugins/provider-pi/src/bridge/bridge.model-resolution.test.ts b/plugins/provider-pi/src/bridge/bridge.model-resolution.test.ts
new file mode 100644
index 000000000..0d4cc54a5
--- /dev/null
+++ b/plugins/provider-pi/src/bridge/bridge.model-resolution.test.ts
@@ -0,0 +1,83 @@
+import { afterEach, beforeEach, expect, it } from "vitest";
+import { z } from "zod";
+import {
+  FULL_PERMISSION_OPTIONS,
+  type FakePiBridgeHarness,
+  startFakePiBridge,
+} from "./test-support.js";
+
+let harness: FakePiBridgeHarness;
+
+beforeEach(async () => {
+  harness = await startFakePiBridge({
+    prefix: "bb-pi-model-resolution-",
+    initialize: true,
+  });
+}, 30_000);
+
+afterEach(async () => {
+  await harness.teardown();
+}, 30_000);
+
+for (const warm of [false, true]) {
+  it.each([
+    ["fake-gateway/vendor/slash-model", 64_000],
+    ["vendor/slash-model", 64_000],
+    ["fake-provider/fake-model", 200_000],
+    ["fake-gateway/vendor/shared-model", 64_000],
+  ])(
+    `starts the exact model %s with a ${warm ? "warm" : "cold"} catalog`,
+    async (model, contextWindow) => {
+      if (warm) {
+        const catalog = await harness.request(1, "model/list", {
+          cwd: harness.workspaceDir,
+        });
+        expect(catalog.error).toBeUndefined();
+      }
+      const threadId = "thr_model_resolution";
+      const response = await harness.startThread(threadId, {
+        options: { ...FULL_PERMISSION_OPTIONS, model },
+      });
+      expect(response.error, JSON.stringify(response)).toBeUndefined();
+      const { providerThreadId } = z
+        .object({ providerThreadId: z.string() })
+        .parse(response.result);
+      const turn = await harness.request(2, "turn/start", {
+        threadId,
+        providerThreadId,
+        clientRequestId: "creq_ab23456789",
+        input: [{ type: "text", text: "hello", mentions: [] }],
+        options: { ...FULL_PERMISSION_OPTIONS, model },
+      });
+      expect(turn.error, JSON.stringify(turn)).toBeUndefined();
+      await harness.waitForTurnBoundary(threadId, 0);
+      expect(harness.deltasOf(threadId)).toContainEqual(
+        expect.objectContaining({ kind: "contextWindow", size: contextWindow }),
+      );
+    },
+    60_000,
+  );
+
+  it.each([
+    ["vendor/shared-model", "Ambiguous Pi model"],
+    ["fake-provider/missing", "Failed to resolve Pi model"],
+    ["unknown/missing", "Failed to resolve Pi model"],
+  ])(
+    `rejects %s during resolution with a ${warm ? "warm" : "cold"} catalog`,
+    async (model, message) => {
+      if (warm) {
+        const catalog = await harness.request(1, "model/list", {
+          cwd: harness.workspaceDir,
+        });
+        expect(catalog.error).toBeUndefined();
+      }
+      const response = await harness.startThread("thr_model_resolution", {
+        options: { ...FULL_PERMISSION_OPTIONS, model },
+      });
+      expect(response.error).toMatchObject({
+        message: expect.stringContaining(message),
+      });
+    },
+    60_000,
+  );
+}
diff --git a/plugins/provider-pi/src/bridge/fake-pi-rpc.mjs b/plugins/provider-pi/src/bridge/fake-pi-rpc.mjs
index 726fd02bb..ce25399e8 100644
--- a/plugins/provider-pi/src/bridge/fake-pi-rpc.mjs
+++ b/plugins/provider-pi/src/bridge/fake-pi-rpc.mjs
@@ -106,6 +106,19 @@ const MODELS = [
     reasoning: false,
     contextWindow: 32_000,
   },
+  ...[
+    ["fake-gateway", "vendor/slash-model"],
+    ["fake-gateway", "fake-provider/fake-model"],
+    ["fake-gateway", "vendor/shared-model"],
+    ["other-gateway", "vendor/shared-model"],
+  ].map(([provider, id]) => ({
+    id,
+    name: id,
+    provider,
+    input: ["text"],
+    reasoning: false,
+    contextWindow: 64_000,
+  })),
 ];
 
 let model = MODELS[0];
@@ -127,9 +140,15 @@ if (process.env.FAKE_PI_SPAWN_COUNTER_FILE) {
 const ignoreRequestedModel =
   process.env.FAKE_PI_MISMATCH_FIRST_SPAWN === "1" && spawnIndex === 1;
 if (requestedModel !== undefined && !ignoreRequestedModel) {
-  const [provider, id] = requestedModel.split("/");
+  const requestedProvider = flag("--provider");
+  const candidates = MODELS.filter(
+    (entry) => !requestedProvider || entry.provider === requestedProvider,
+  );
   model =
-    MODELS.find((entry) => entry.provider === provider && entry.id === id) ??
+    candidates.find(
+      (entry) => `${entry.provider}/${entry.id}` === requestedModel,
+    ) ??
+    candidates.find((entry) => entry.id === requestedModel) ??
     MODELS[0];
 }
 let thinkingLevel = flag("--thinking") ?? "medium";

Actual pinned Pi resolver check

Save this script outside the checkout as /tmp/pi-model-resolver.mjs and run node /tmp/pi-model-resolver.mjs from the checkout root. It uses the installed pinned Pi resolver, not a copied algorithm, and an in-memory synthetic catalog.

import { pathToFileURL } from "node:url";

const resolverUrl = pathToFileURL(
  `${process.cwd()}/plugins/provider-pi/node_modules/@earendil-works/pi-coding-agent/dist/core/model-resolver.js`,
);
const { resolveCliModel } = await import(resolverUrl);
const models = [
  { provider: "fake-gateway", id: "vendor/slash-model", name: "Slash model" },
];
const result = resolveCliModel({
  cliModel: "vendor/slash-model",
  modelRuntime: {
    getModels: () => models,
    hasConfiguredAuth: () => true,
  },
});
console.log(JSON.stringify(result, null, 2));
if (result.model?.provider !== "fake-gateway" || result.model.id !== "vendor/slash-model") {
  process.exitCode = 1;
}

Both runs exit 0 and print:

{
  "model": {
    "provider": "fake-gateway",
    "id": "vendor/slash-model",
    "name": "Slash model"
  }
}

Raw logs and scratch artifacts stay local; the public report contains only the necessary test code and error evidence. This is a nonvisual defect, so no screenshot is needed.

> AGENT GENERATED