From 936523091f573da4bfdc7ae381484c669c917ddd Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 7 Nov 2026 18:31:48 +0000 Subject: [PATCH 1/3] test(pi): reproduce #32575 slash-containing Pi model providers The set_model request must carry the provider and id that get_available_models reported, even when either contains a slash. AI-assisted; verified with the tests and tools listed above. --- .../provider-pi/src/server/adapter.test.ts | 51 +++++++++++++++++++ 1 file changed, 51 insertions(+) diff --git a/packages/provider-pi/src/server/adapter.test.ts b/packages/provider-pi/src/server/adapter.test.ts index 67e7cc6aca8e..b78ea8f8182b 100644 --- a/packages/provider-pi/src/server/adapter.test.ts +++ b/packages/provider-pi/src/server/adapter.test.ts @@ -2243,6 +2243,57 @@ describe("PiAdapterV2", () => { }).pipe(Effect.scoped, Effect.provide(layerTest)), ); + it.effect("selects the provider and id Pi reported when either contains a slash", () => + Effect.gen(function* () { + const fake = yield* makeFakePi; + const hub = "hub=https://hub.example:8318/v1"; + fake.queueModels([ + { provider: "openrouter", id: "openrouter/auto" }, + { provider: hub, id: "claude-haiku-4-5" }, + ]); + const { runtime, takeEvent } = yield* openRuntime(fake); + const providerThread = yield* runtime.ensureThread({ + threadId: THREAD_ID, + modelSelection: modelSelection("default"), + runtimePolicy, + }); + + yield* startTurn( + runtime, + providerThread, + "default", + [], + "First", + modelSelection("openrouter/openrouter/auto"), + ); + const openRouterRequest = yield* fake.takeRequest("set_model"); + assert.deepEqual( + { provider: openRouterRequest["provider"], modelId: openRouterRequest["modelId"] }, + { provider: "openrouter", modelId: "openrouter/auto" }, + ); + yield* fake.takeRequest("prompt"); + yield* fake.emit({ type: "agent_start" }); + yield* fake.emit({ type: "agent_end", messages: [], willRetry: false }); + yield* fake.emit({ type: "agent_settled" }); + yield* takeEvent((event) => event.type === "turn.terminal"); + + yield* startTurn( + runtime, + providerThread, + "default", + [], + "Second", + modelSelection(`${hub}/claude-haiku-4-5`), + 2, + ); + const hubRequest = yield* fake.takeRequest("set_model"); + assert.deepEqual( + { provider: hubRequest["provider"], modelId: hubRequest["modelId"] }, + { provider: hub, modelId: "claude-haiku-4-5" }, + ); + }).pipe(Effect.scoped, Effect.provide(layerTest)), + ); + it.effect.each(["anthropic/model-b", "default"])( "preserves explicit thinking when switching models to %s in the same runtime", (targetModel) => From 23049bc81bec766f78737ff3020f72b7fe08ddba Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 7 Nov 2026 18:37:37 +0000 Subject: [PATCH 2/3] fix(pi): select models whose Pi provider name contains a slash Pi providers such as pi-llama-cpp's `llama-server=http://host:port` contain "/", and model ids can too (`openrouter` / `openrouter/auto`), so splitting a `provider/id` slug at any single "/" can send set_model a provider Pi does not know. Resolve the slug against the `{provider, id}` pairs get_available_models reported, falling back to the first-"/" split only for slugs Pi did not report. The adapter keeps the inventory it already fetches per session; text generation asks for it once on its short-lived connection before set_model. AI-assisted; verified with the tests and tools listed above. --- packages/provider-pi/src/server/adapter.ts | 10 +++--- packages/provider-pi/src/server/rpc.ts | 15 ++++++++- .../src/server/textGeneration.test.ts | 33 +++++++++++++++++++ .../provider-pi/src/server/textGeneration.ts | 7 ++-- 4 files changed, 57 insertions(+), 8 deletions(-) diff --git a/packages/provider-pi/src/server/adapter.ts b/packages/provider-pi/src/server/adapter.ts index 8ed02b6917fc..4cc50cf162b9 100644 --- a/packages/provider-pi/src/server/adapter.ts +++ b/packages/provider-pi/src/server/adapter.ts @@ -59,7 +59,7 @@ import { import { turnScopedSelectionTransition } from "@t3tools/provider-core/server/selectionTransition"; import { makePiRpcConnection, - parsePiModelSlug, + resolvePiModelSlug, piRecordField as recordField, piRecordNumber as recordNumber, piRecordString as recordString, @@ -585,7 +585,7 @@ export const makePiAdapterV2 = Effect.fn("makePiAdapterV2")(function* ( let baselineThinking: string | null = null; let contextWindow: number | null = null; const modelContextWindows = new Map(); - let modelsDiscovered = false; + let discoveredModels: ReadonlyArray | null = null; // Prompt ACKs lack IDs; retain session send order and ownership so late ACKs cannot affect new turns. const pendingPromptResponses: Array = []; const pendingCompactResponses: Array<{ @@ -2462,14 +2462,14 @@ export const makePiAdapterV2 = Effect.fn("makePiAdapterV2")(function* ( } } const stateData = yield* request({ type: "get_state" }); - if (!modelsDiscovered) { + if (discoveredModels === null) { const modelsData = yield* request({ type: "get_available_models" }).pipe( Effect.orElseSucceed(() => undefined), ); const models = recordField(modelsData, "models"); if (Array.isArray(models)) { for (const model of models) rememberModelContextWindow(model); - modelsDiscovered = true; + discoveredModels = models; } } contextWindow = rememberModelContextWindow(recordField(stateData, "model")); @@ -2567,7 +2567,7 @@ export const makePiAdapterV2 = Effect.fn("makePiAdapterV2")(function* ( appliedThinking = baselineThinking; } } else if (modelSelection.model !== appliedModel) { - const parsed = parsePiModelSlug(modelSelection.model); + const parsed = resolvePiModelSlug(modelSelection.model, discoveredModels); if (parsed === null) { return yield* protocolError( `Pi model '${modelSelection.model}' must use provider/model format`, diff --git a/packages/provider-pi/src/server/rpc.ts b/packages/provider-pi/src/server/rpc.ts index 98eacd790139..533a56ba8afc 100644 --- a/packages/provider-pi/src/server/rpc.ts +++ b/packages/provider-pi/src/server/rpc.ts @@ -58,7 +58,20 @@ export function piRecordNumber(input: unknown, key: string): number | undefined return Predicate.isNumber(value) && Number.isFinite(value) ? value : undefined; } -export function parsePiModelSlug(slug: string): { provider: string; modelId: string } | null { +// Pi provider names and model ids may both contain "/", so a slug only splits +// reliably through the `{provider, id}` pairs `get_available_models` reported. +export function resolvePiModelSlug( + slug: string, + reportedModels: unknown, +): { provider: string; modelId: string } | null { + if (Array.isArray(reportedModels)) { + for (const model of reportedModels) { + const provider = piRecordString(model, "provider"); + const modelId = piRecordString(model, "id"); + if (provider !== undefined && modelId !== undefined && `${provider}/${modelId}` === slug) + return { provider, modelId }; + } + } const separator = slug.indexOf("/"); if (separator <= 0 || separator === slug.length - 1) return null; return { provider: slug.slice(0, separator), modelId: slug.slice(separator + 1) }; diff --git a/packages/provider-pi/src/server/textGeneration.test.ts b/packages/provider-pi/src/server/textGeneration.test.ts index 0e6a8a3c0778..eda86cd2e62a 100644 --- a/packages/provider-pi/src/server/textGeneration.test.ts +++ b/packages/provider-pi/src/server/textGeneration.test.ts @@ -19,6 +19,7 @@ const makeFakePi = ( reply: string, setModelError?: string, turnEvents: ReadonlyArray> = [], + models?: ReadonlyArray>, ) => Effect.gen(function* () { const stdout = yield* Queue.unbounded(); @@ -41,6 +42,8 @@ const makeFakePi = ( yield* emit({ type: "agent_settled" }); } else if (record["type"] === "get_last_assistant_text") { yield* emit({ ...response, success: true, data: { text: reply } }); + } else if (record["type"] === "get_available_models" && models !== undefined) { + yield* emit({ ...response, success: true, data: { models } }); } else if (record["type"] === "set_model" && setModelError !== undefined) { yield* emit({ ...response, success: false, error: setModelError }); } else { @@ -124,6 +127,36 @@ it.effect("names the model and the disabled extensions when Pi cannot load it", }), ); +it.effect("selects the provider and id Pi reported when the provider contains a slash", () => + Effect.gen(function* () { + const provider = "llama-server=http://127.0.0.1:8080"; + const pi = yield* makeFakePi( + JSON.stringify({ subject: "Update README", body: "" }), + undefined, + [], + [{ provider, id: "qwen3" }], + ); + const textGeneration = yield* makePiTextGeneration( + { enabled: true, binaryPath: "pi", launchArgs: "", customModels: [] }, + {}, + ).pipe(Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, pi.spawner)); + + yield* textGeneration.generateCommitMessage({ + cwd: process.cwd(), + branch: "main", + stagedSummary: "M README.md", + stagedPatch: "diff --git a/README.md b/README.md", + modelSelection: createModelSelection(ProviderInstanceId.make("pi"), `${provider}/qwen3`), + }); + + const setModel = pi.received.find((record) => record["type"] === "set_model"); + assert.deepEqual( + { provider: setModel?.["provider"], modelId: setModel?.["modelId"] }, + { provider, modelId: "qwen3" }, + ); + }), +); + const failedTurnWith = (errorMessage: string) => ({ type: "message_end", message: { role: "assistant", stopReason: "error", errorMessage }, diff --git a/packages/provider-pi/src/server/textGeneration.ts b/packages/provider-pi/src/server/textGeneration.ts index 40697f2d4280..8b75b3c3c0ba 100644 --- a/packages/provider-pi/src/server/textGeneration.ts +++ b/packages/provider-pi/src/server/textGeneration.ts @@ -7,7 +7,7 @@ import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; import { TextGenerationError } from "@t3tools/contracts"; import type { PiSettings } from "../settings.ts"; -import { makePiRpcConnection, parsePiModelSlug, piRecordString } from "./rpc.ts"; +import { makePiRpcConnection, piRecordField, piRecordString, resolvePiModelSlug } from "./rpc.ts"; import { buildPiRpcLaunch, resolvePiLaunchArgs } from "./mcpInjection.ts"; import * as TextGenerationOperations from "@t3tools/provider-core/server/textGenerationOperations"; @@ -61,7 +61,10 @@ export const makePiTextGeneration = Effect.fn("makePiTextGeneration")(function* if (modelSelection.model !== "default") { // Reject unusable custom slugs; silently using Pi's default would misreport the requested model. - const parsed = parsePiModelSlug(modelSelection.model); + const inventory = yield* connection + .request({ type: "get_available_models" }) + .pipe(Effect.orElseSucceed(() => undefined)); + const parsed = resolvePiModelSlug(modelSelection.model, piRecordField(inventory, "models")); if (parsed === null) { return yield* new TextGenerationError({ operation, From 0e41937e52a50a42a84d5c20ac493a2c41354eb4 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 10 Nov 2026 13:33:38 +0000 Subject: [PATCH 3/3] fix(pi): refresh Pi's model list when a selected slug is not in it Providers added after the session's first discovery (extension reloads, models.json edits) otherwise fell back to the first-slash split. Co-Authored-By: Claude Opus 5.5 --- .../provider-pi/src/server/adapter.test.ts | 10 ++++--- packages/provider-pi/src/server/adapter.ts | 27 ++++++++++++------- packages/provider-pi/src/server/rpc.ts | 24 +++++++++++------ 3 files changed, 39 insertions(+), 22 deletions(-) diff --git a/packages/provider-pi/src/server/adapter.test.ts b/packages/provider-pi/src/server/adapter.test.ts index 37f7e67cc6b8..21ea842b917e 100644 --- a/packages/provider-pi/src/server/adapter.test.ts +++ b/packages/provider-pi/src/server/adapter.test.ts @@ -2278,10 +2278,7 @@ describe("PiAdapterV2", () => { Effect.gen(function* () { const fake = yield* makeFakePi; const hub = "hub=https://hub.example:8318/v1"; - fake.queueModels([ - { provider: "openrouter", id: "openrouter/auto" }, - { provider: hub, id: "claude-haiku-4-5" }, - ]); + fake.queueModels([{ provider: "openrouter", id: "openrouter/auto" }]); const { runtime, takeEvent } = yield* openRuntime(fake); const providerThread = yield* runtime.ensureThread({ threadId: THREAD_ID, @@ -2308,6 +2305,11 @@ describe("PiAdapterV2", () => { yield* fake.emit({ type: "agent_settled" }); yield* takeEvent((event) => event.type === "turn.terminal"); + // A provider registered after the session started, e.g. by an extension reload. + fake.queueModels([ + { provider: "openrouter", id: "openrouter/auto" }, + { provider: hub, id: "claude-haiku-4-5" }, + ]); yield* startTurn( runtime, providerThread, diff --git a/packages/provider-pi/src/server/adapter.ts b/packages/provider-pi/src/server/adapter.ts index 55e6c9f22545..90c262b28108 100644 --- a/packages/provider-pi/src/server/adapter.ts +++ b/packages/provider-pi/src/server/adapter.ts @@ -58,6 +58,7 @@ import { } from "@t3tools/provider-core/server/failure"; import { turnScopedSelectionTransition } from "@t3tools/provider-core/server/selectionTransition"; import { + findReportedPiModel, makePiRpcConnection, resolvePiModelSlug, piRecordField as recordField, @@ -2420,6 +2421,17 @@ export const makePiAdapterV2 = Effect.fn("makePiAdapterV2")(function* ( Effect.forkIn(scope), ); + const discoverModels = Effect.fnUntraced(function* () { + const modelsData = yield* request({ type: "get_available_models" }).pipe( + Effect.orElseSucceed(() => undefined), + ); + const models = recordField(modelsData, "models"); + if (Array.isArray(models)) { + for (const model of models) rememberModelContextWindow(model); + discoveredModels = models; + } + }); + const registerThread = Effect.fnUntraced(function* ( threadInput: ProviderAdapter.ProviderAdapterV2EnsureThreadInput, publish = true, @@ -2468,16 +2480,7 @@ export const makePiAdapterV2 = Effect.fn("makePiAdapterV2")(function* ( } } const stateData = yield* request({ type: "get_state" }); - if (discoveredModels === null) { - const modelsData = yield* request({ type: "get_available_models" }).pipe( - Effect.orElseSucceed(() => undefined), - ); - const models = recordField(modelsData, "models"); - if (Array.isArray(models)) { - for (const model of models) rememberModelContextWindow(model); - discoveredModels = models; - } - } + if (discoveredModels === null) yield* discoverModels(); contextWindow = rememberModelContextWindow(recordField(stateData, "model")); // Capture each baseline before applying selections so our own choices cannot become Pi defaults. if (baselineModel === null && appliedModel === null) { @@ -2573,6 +2576,10 @@ export const makePiAdapterV2 = Effect.fn("makePiAdapterV2")(function* ( appliedThinking = baselineThinking; } } else if (modelSelection.model !== appliedModel) { + // Extensions and models.json edits can add providers after the first discovery. + if (findReportedPiModel(modelSelection.model, discoveredModels) === undefined) { + yield* discoverModels(); + } const parsed = resolvePiModelSlug(modelSelection.model, discoveredModels); if (parsed === null) { return yield* protocolError( diff --git a/packages/provider-pi/src/server/rpc.ts b/packages/provider-pi/src/server/rpc.ts index 533a56ba8afc..9b016051a150 100644 --- a/packages/provider-pi/src/server/rpc.ts +++ b/packages/provider-pi/src/server/rpc.ts @@ -60,18 +60,26 @@ export function piRecordNumber(input: unknown, key: string): number | undefined // Pi provider names and model ids may both contain "/", so a slug only splits // reliably through the `{provider, id}` pairs `get_available_models` reported. +export function findReportedPiModel( + slug: string, + reportedModels: unknown, +): { provider: string; modelId: string } | undefined { + if (!Array.isArray(reportedModels)) return undefined; + for (const model of reportedModels) { + const provider = piRecordString(model, "provider"); + const modelId = piRecordString(model, "id"); + if (provider !== undefined && modelId !== undefined && `${provider}/${modelId}` === slug) + return { provider, modelId }; + } + return undefined; +} + export function resolvePiModelSlug( slug: string, reportedModels: unknown, ): { provider: string; modelId: string } | null { - if (Array.isArray(reportedModels)) { - for (const model of reportedModels) { - const provider = piRecordString(model, "provider"); - const modelId = piRecordString(model, "id"); - if (provider !== undefined && modelId !== undefined && `${provider}/${modelId}` === slug) - return { provider, modelId }; - } - } + const reported = findReportedPiModel(slug, reportedModels); + if (reported !== undefined) return reported; const separator = slug.indexOf("/"); if (separator <= 0 || separator === slug.length - 1) return null; return { provider: slug.slice(0, separator), modelId: slug.slice(separator + 1) };