diff --git a/packages/provider-pi/src/server/adapter.test.ts b/packages/provider-pi/src/server/adapter.test.ts index 06ad17f0568b..21ea842b917e 100644 --- a/packages/provider-pi/src/server/adapter.test.ts +++ b/packages/provider-pi/src/server/adapter.test.ts @@ -2274,6 +2274,59 @@ 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" }]); + 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"); + + // 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, + "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) => diff --git a/packages/provider-pi/src/server/adapter.ts b/packages/provider-pi/src/server/adapter.ts index a82a7f45f4e4..90c262b28108 100644 --- a/packages/provider-pi/src/server/adapter.ts +++ b/packages/provider-pi/src/server/adapter.ts @@ -58,8 +58,9 @@ import { } from "@t3tools/provider-core/server/failure"; import { turnScopedSelectionTransition } from "@t3tools/provider-core/server/selectionTransition"; import { + findReportedPiModel, makePiRpcConnection, - parsePiModelSlug, + resolvePiModelSlug, piRecordField as recordField, piRecordNumber as recordNumber, piRecordString as recordString, @@ -589,7 +590,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<{ @@ -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 (!modelsDiscovered) { - 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; - } - } + 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,7 +2576,11 @@ export const makePiAdapterV2 = Effect.fn("makePiAdapterV2")(function* ( appliedThinking = baselineThinking; } } else if (modelSelection.model !== appliedModel) { - const parsed = parsePiModelSlug(modelSelection.model); + // 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( `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..9b016051a150 100644 --- a/packages/provider-pi/src/server/rpc.ts +++ b/packages/provider-pi/src/server/rpc.ts @@ -58,7 +58,28 @@ 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 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 { + 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) }; 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,