diff --git a/docs/adr/0025-ohmypi-model-presets.md b/docs/adr/0025-ohmypi-model-presets.md new file mode 100644 index 0000000000000..6facf4cbefc6f --- /dev/null +++ b/docs/adr/0025-ohmypi-model-presets.md @@ -0,0 +1,46 @@ +# ADR 0025: Select OhMyPi model presets per thread + +- Status: accepted +- Date: 2026-10-26 +- Compared with upstream: `pingdotgg/t3code` at `6ffde5ba3` (2026-10-26) + +OhMyPi (`omp`) is a Pi fork that T3 runs through the Pi provider. Its model +presets assign models to every role (`default`, `task`, `plan`, `smol`, ...) +and a default thinking level. Neither Pi RPC nor omp's ACP mode exposes them, +so T3 could only change the main model. + +When the Pi binary reports an `omp/` version, discovery reads +`omp config get modelPresets --json` and lists each preset as a model with the +slug `preset:` and a "Preset" qualifier. Its Thinking select is the one +for the preset's main model and starts at the preset's level. Config reads pass +the instance's `--profile` and time out on their own, so a slow read only loses +the presets, not the discovered models. + +Applying a preset with omp's own `/modelpreset switch` writes roles to the +user's global config, and every running omp process reloads it, so one thread +would change all the others and the user's terminal. T3 instead starts every +Pi session with its own overlay file in T3's cache, listed last in +`PI_CONFIG_FILES` (omp reads it like `--config`; plain Pi ignores it), empty +until a turn selects a preset. Selecting one rewrites the overlay with the preset's +roles, `null` tombstones for roles the preset leaves out (as `/modelpreset +switch` clears them), and its thinking level, read fresh from omp; then the +turn sets the main model and thinking over RPC. Selecting a plain model empties +the overlay again. omp watches overlay files and reloads them about 200ms after +a write, so role models switch from the next role lookup. A restart would avoid +that delay, but ordinary message dispatch never consults the adapter's +selection-transition plan, so switching has to work inside the live session. + +Presets without a `default` role are not listed, because T3 sets the main model +from it, and one written as an alias rather than `provider/model` fails with a +clear error. omp's `--smol`, `--slow`, and `--plan` flags and their +`PI_*_MODEL` variables are runtime overrides that outrank any overlay. `omp +config get` cannot read `--config` overlays from the instance's launch +arguments and returns same-name presets merged across config layers, where omp +takes the highest layer's entry whole; both are accepted gaps. Text +generation has no per-model filter, so a preset chosen there uses only its +main model. + +When updating from upstream, keep presets inside the Pi provider and keep the +per-process overlay; do not switch presets by writing omp's global config. +Remove this if Pi or omp exposes presets over RPC, or if upstream adds OhMyPi +support. diff --git a/docs/user/providers-pi.md b/docs/user/providers-pi.md index 123258f6ccb43..85d05497318cc 100644 --- a/docs/user/providers-pi.md +++ b/docs/user/providers-pi.md @@ -51,6 +51,24 @@ Pi loads its normal user and project extensions. Blocking `select`, `confirm`, ` dialogs work in T3 Code. Notifications appear in the work log. Pi terminal decoration such as titles, status lines, and widgets does not have a T3 Code equivalent. +## OhMyPi Model Presets + +If Pi's binary path points to [OhMyPi](https://github.com/can1357/oh-my-pi) (`omp`), its saved +model presets appear in the model picker labelled **Preset**. Save presets in `omp` with +`/modelpreset save `, then refresh the provider in Settings to see new ones. The picker lists +presets saved in your user configuration; presets saved only in a project's configuration do not +appear. + +Choosing a preset applies all of its role models and its thinking level to that thread only. Other +threads and `omp` sessions outside T3 Code keep their own models. You can switch presets, or switch +between a preset and a single model, at any point in a thread. Edits to a preset apply the next time +a thread switches to it or its session restarts. Use the picker rather than `/modelpreset switch`, +which changes OhMyPi's global configuration. + +A preset needs a main (`default`) model written as `provider/model`. The `--smol`, `--slow`, and +`--plan` launch arguments, and the matching `PI_SMOL_MODEL`, `PI_SLOW_MODEL`, and `PI_PLAN_MODEL` +environment variables, override those roles in every preset. + ## Permission Modes T3 Code applies the composer permission mode through Pi's blocking tool hook: diff --git a/packages/provider-pi/src/server/adapter.test.ts b/packages/provider-pi/src/server/adapter.test.ts index 544ac5cce66a3..8545c63898350 100644 --- a/packages/provider-pi/src/server/adapter.test.ts +++ b/packages/provider-pi/src/server/adapter.test.ts @@ -165,6 +165,7 @@ interface FakePi { readonly failNextState: () => void; readonly deferNextLifecycle: (type: "switch_session" | "new_session" | "fork") => void; readonly deferNextModelSelection: () => void; + readonly failNextModelSelection: () => void; readonly queueModels: (models: ReadonlyArray) => void; readonly vetoNextNewSession: () => void; readonly allRequests: () => ReadonlyArray; @@ -190,6 +191,21 @@ const recordedIdleState = (sessionFile: string) => ({ pendingMessageCount: 0, }); +const exitedHandle = (stdout: string) => + ChildProcessSpawner.makeHandle({ + pid: ChildProcessSpawner.ProcessId(FAKE_PID), + exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(0)), + isRunning: Effect.succeed(false), + kill: () => Effect.void, + unref: Effect.succeed(Effect.void), + stdin: Sink.drain, + stdout: Stream.succeed(new TextEncoder().encode(stdout)), + stderr: Stream.empty, + all: Stream.empty, + getInputFd: () => Sink.drain, + getOutputFd: () => Stream.empty, + }); + const makeFakePi: Effect.Effect = Effect.gen(function* () { const stdout = yield* Queue.unbounded(); const requests = yield* Queue.unbounded(); @@ -201,6 +217,7 @@ const makeFakePi: Effect.Effect = Effect.gen(function* () { let deferState = false; let deferredStateRequest: PiRpcRecord | undefined; let failState = false; + let failModelSelection = false; let vetoSwitch = false; let vetoNewSession = false; let deferredLifecycle: string | undefined; @@ -254,6 +271,10 @@ const makeFakePi: Effect.Effect = Effect.gen(function* () { return base; case "get_available_models": return { ...base, data: { models } }; + case "set_model": + if (!failModelSelection) return base; + failModelSelection = false; + return { ...base, success: false, error: "No API key for anthropic" }; case "new_session": { const cancelled = vetoNewSession; vetoNewSession = false; @@ -371,6 +392,9 @@ const makeFakePi: Effect.Effect = Effect.gen(function* () { deferNextModelSelection: () => { deferredLifecycle = "set_model"; }, + failNextModelSelection: () => { + failModelSelection = true; + }, queueModels: (value) => { models = value; }, @@ -428,6 +452,64 @@ const makeAdapter = Effect.fnUntraced(function* ( }).pipe(Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner)); }); +const OMP_CONFIG_VALUES: Record = { + modelPresets: { + Anthropic: { + modelRoles: { default: "anthropic/claude-opus:high", task: "anthropic/claude-sonnet" }, + defaultThinkingLevel: "auto", + }, + Auto: { modelRoles: { default: "openai/gpt" }, defaultThinkingLevel: "auto" }, + Unleveled: { modelRoles: { default: "openai/gpt" } }, + }, + modelRoles: { default: "openai/gpt", designer: "openai/gpt-mini" }, + defaultThinkingLevel: "low", +}; + +/** Answers `omp config get --json` and hands every other spawn to the fake Pi. */ +const makeOmpPresetAdapter = (fake: FakePi) => { + const spawner = ChildProcessSpawner.make((command) => { + const args = ChildProcess.isStandardCommand(command) ? command.args : []; + if (!args.includes("config")) return fake.spawner.spawn(command); + return Effect.succeed( + exitedHandle( + encodeJsonLine({ value: OMP_CONFIG_VALUES[args[args.indexOf("config") + 2] ?? ""] }), + ), + ); + }); + return makePiAdapterV2({ + instanceId: PI_INSTANCE_ID, + settings: { enabled: true, binaryPath: "omp", launchArgs: "", customModels: [] }, + environment: {}, + }).pipe(Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner)); +}; + +const openOmpRuntime = Effect.fnUntraced(function* ( + fake: FakePi, + model: string, + providerSessionId = SESSION_ID, +) { + const adapter = yield* makeOmpPresetAdapter(fake); + const runtime = yield* adapter.openSession({ + threadId: THREAD_ID, + providerSessionId, + modelSelection: modelSelection(model), + runtimePolicy, + }); + const emitted = yield* Queue.unbounded(); + yield* runtime.events.pipe( + Stream.runForEach((event) => Queue.offer(emitted, event)), + Effect.forkScoped, + ); + const takeEvent = (predicate: (event: ProviderAdapter.ProviderAdapterV2Event) => boolean) => + Effect.gen(function* () { + while (true) { + const event = yield* Queue.take(emitted); + if (predicate(event)) return event; + } + }); + return { runtime, takeEvent }; +}); + const openRuntime = Effect.fnUntraced(function* ( fake: FakePi, model = "default", @@ -1465,13 +1547,14 @@ describe("PiAdapterV2", () => { ...fake, spawner: ChildProcessSpawner.make((command) => Effect.acquireRelease( - fake.spawner - .spawn(command) - .pipe( - Effect.map((child) => - ChildProcessSpawner.makeHandle({ ...child, exitCode: Deferred.await(exited) }), - ), + fake.spawner.spawn(command).pipe( + Effect.map((child) => + ChildProcessSpawner.makeHandle({ + ...child, + exitCode: Deferred.await(exited), + }), ), + ), () => Effect.sync(() => { processReleased = true; @@ -1818,6 +1901,157 @@ describe("PiAdapterV2", () => { }).pipe(Effect.scoped, Effect.provide(layerTest)), ); + it.effect("switches an OhMyPi thread between presets and models through its own overlay", () => + Effect.gen(function* () { + const fake = yield* makeFakePi; + const { runtime, takeEvent } = yield* openOmpRuntime(fake, "default"); + const overlayPath = fake.lastSpawn().env.PI_CONFIG_FILES!; + const readOverlay = Effect.gen(function* () { + return decodeJsonLine(yield* (yield* FileSystem.FileSystem).readFileString(overlayPath)); + }); + assert.deepEqual(yield* readOverlay, {}); + const providerThread = yield* runtime.ensureThread({ + threadId: THREAD_ID, + modelSelection: modelSelection("default"), + runtimePolicy, + }); + const settle = Effect.gen(function* () { + 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, "preset:Anthropic"); + const anthropicModel = yield* fake.takeRequest("set_model"); + assert.deepEqual( + [anthropicModel["provider"], anthropicModel["modelId"]], + ["anthropic", "claude-opus"], + ); + assert.equal((yield* fake.takeRequest("set_thinking_level"))["level"], "high"); + // Roles the preset leaves out are tombstoned so global roles cannot leak in. + assert.deepEqual(yield* readOverlay, { + modelRoles: { + default: "anthropic/claude-opus:high", + designer: null, + task: "anthropic/claude-sonnet", + }, + defaultThinkingLevel: "auto", + }); + yield* settle; + + yield* startTurn(runtime, providerThread, "preset:Auto", [], "Hello pi", undefined, 2); + const autoModel = yield* fake.takeRequest("set_model"); + assert.deepEqual([autoModel["provider"], autoModel["modelId"]], ["openai", "gpt"]); + assert.equal((yield* fake.takeRequest("set_thinking_level"))["level"], "auto"); + assert.deepEqual(yield* readOverlay, { + modelRoles: { default: "openai/gpt", designer: null }, + defaultThinkingLevel: "auto", + }); + yield* settle; + + yield* startTurn(runtime, providerThread, "xai/grok-4.6", [], "Hello pi", undefined, 3); + const plainModel = yield* fake.takeRequest("set_model"); + assert.deepEqual([plainModel["provider"], plainModel["modelId"]], ["xai", "grok-4.6"]); + assert.deepEqual(yield* readOverlay, {}); + }).pipe(Effect.scoped, Effect.provide(layerTest)), + ); + + it.effect("returns a preset without a thinking level to omp's configured level", () => + Effect.gen(function* () { + const fake = yield* makeFakePi; + // A resumed transcript's level must not stand in for omp's configured default. + fake.queueState({ thinkingLevel: "high" }); + const { runtime, takeEvent } = yield* openOmpRuntime(fake, "preset:Anthropic"); + const providerThread = yield* runtime.ensureThread({ + threadId: THREAD_ID, + modelSelection: modelSelection("preset:Anthropic"), + runtimePolicy, + }); + yield* startTurn(runtime, providerThread, "preset:Anthropic"); + assert.equal((yield* fake.takeRequest("set_thinking_level"))["level"], "high"); + 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, "preset:Unleveled", [], "Hello pi", undefined, 2); + assert.equal((yield* fake.takeRequest("set_thinking_level"))["level"], "low"); + }).pipe(Effect.scoped, Effect.provide(layerTest)), + ); + + it.effect("keeps the overlay file name short for long session ids", () => + Effect.gen(function* () { + const fake = yield* makeFakePi; + // Delegated-task thread ids embed request keys of up to 256 characters. + yield* openOmpRuntime( + fake, + "default", + ProviderSessionId.make(`provider-session:${"k".repeat(300)}`), + ); + const overlayPath = fake.lastSpawn().env.PI_CONFIG_FILES!; + assert.isBelow(overlayPath.slice(overlayPath.lastIndexOf("/") + 1).length, 100); + assert.deepEqual( + decodeJsonLine(yield* (yield* FileSystem.FileSystem).readFileString(overlayPath)), + {}, + ); + }).pipe(Effect.scoped, Effect.provide(layerTest)), + ); + + it.effect("knows a preset's context window before its first turn", () => + Effect.gen(function* () { + const fake = yield* makeFakePi; + fake.queueModels([ + { provider: "anthropic", id: "claude-opus", contextWindow: 1_000_000 }, + { provider: "openai", id: "gpt", contextWindow: 200_000 }, + ]); + const { runtime } = yield* openOmpRuntime(fake, "preset:Anthropic"); + yield* runtime.ensureThread({ + threadId: THREAD_ID, + modelSelection: modelSelection("preset:Anthropic"), + runtimePolicy, + }); + assert.equal(runtime.getModelContextWindow?.(modelSelection("preset:Auto")), 200_000); + }).pipe(Effect.scoped, Effect.provide(layerTest)), + ); + + it.effect("clears a preset overlay whose switch failed when the thread switches back", () => + Effect.gen(function* () { + const fake = yield* makeFakePi; + const { runtime } = yield* openOmpRuntime(fake, "xai/grok-4.6"); + const overlayPath = fake.lastSpawn().env.PI_CONFIG_FILES!; + const providerThread = yield* runtime.ensureThread({ + threadId: THREAD_ID, + modelSelection: modelSelection("xai/grok-4.6"), + runtimePolicy, + }); + fake.failNextModelSelection(); + yield* startTurn(runtime, providerThread, "preset:Anthropic").pipe(Effect.flip); + + yield* startTurn(runtime, providerThread, "xai/grok-4.6", [], "Hello pi", undefined, 2); + yield* fake.takeRequest("prompt"); + assert.deepEqual( + decodeJsonLine(yield* (yield* FileSystem.FileSystem).readFileString(overlayPath)), + {}, + ); + }).pipe(Effect.scoped, Effect.provide(layerTest)), + ); + + it.effect("fails a turn on a missing preset without changing the model", () => + Effect.gen(function* () { + const fake = yield* makeFakePi; + const { runtime } = yield* openOmpRuntime(fake, "preset:Gone"); + const providerThread = yield* runtime.ensureThread({ + threadId: THREAD_ID, + modelSelection: modelSelection("preset:Gone"), + runtimePolicy, + }); + const error = yield* startTurn(runtime, providerThread, "preset:Gone").pipe(Effect.flip); + assert.include(String(error.cause), "preset 'Gone' was not found"); + assert.isUndefined(fake.allRequests().find((record) => record["type"] === "set_model")); + }).pipe(Effect.scoped, Effect.provide(layerTest)), + ); + it.effect.each([ { historical: false, label: "the latest turn" }, { historical: true, label: "a historical turn" }, diff --git a/packages/provider-pi/src/server/adapter.ts b/packages/provider-pi/src/server/adapter.ts index aca13e35643ed..dc901300fe253 100644 --- a/packages/provider-pi/src/server/adapter.ts +++ b/packages/provider-pi/src/server/adapter.ts @@ -30,6 +30,7 @@ import * as Option from "effect/Option"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; +import * as Hash from "effect/Hash"; import * as Fiber from "effect/Fiber"; import * as Queue from "effect/Queue"; import * as Schema from "effect/Schema"; @@ -71,6 +72,16 @@ import { resolvePiLaunchArgs, } from "./mcpInjection.ts"; import { PI_FILE_CHANGE_TOOLS } from "./mcpExtensionSource.ts"; +import { + parseOmpModelSelector, + encodeOmpConfigOverlay, + OMP_AUTO_THINKING, + piModelInventorySlugs, + piPresetNameFromSlug, + piPresetSlug, + readOmpModelPresets, + resolveOmpPreset, +} from "./presets.ts"; export const PI_PROVIDER = ProviderDriverKind.make("pi"); const PI_DRIVER_KIND = PI_PROVIDER; @@ -304,6 +315,7 @@ function providerRef( return { driver: PI_PROVIDER, nativeId, strength }; } +const UNKNOWN_PI_THINKING = ""; const PI_THINKING_LEVELS = new Set(["off", "minimal", "low", "medium", "high", "xhigh", "max"]); interface PiStreamItemState { @@ -461,11 +473,30 @@ export const makePiAdapterV2 = Effect.fn("makePiAdapterV2")(function* ( if (!resolvedLaunchArgs.ok) { return yield* protocolError(resolvedLaunchArgs.message); } + // Each session gets its own OhMyPi config overlay so a preset never touches omp's global config. + const overlayDir = `${host.paths.providerStatusCacheDir.replace(/\\/g, "/")}/pi-presets`; + // Session ids embed thread and project ids; keep the file name well under NAME_MAX. + const overlayName = `${input.providerSessionId.replace(/[^\w-]/g, "_").slice(-64)}-${(Hash.string(input.providerSessionId) >>> 0).toString(36)}`; + const overlayPath = `${overlayDir}/${overlayName}.json`; + const writeOverlay = Effect.fnUntraced(function* (overlay: Record) { + const temporaryPath = `${overlayPath}.tmp`; + // JSON is valid YAML; the rename keeps omp's file watcher from reading a partial write. + yield* fileSystem.writeFileString(temporaryPath, encodeOmpConfigOverlay(overlay)); + yield* fileSystem.rename(temporaryPath, overlayPath); + }); + // omp refuses to start when an overlay file is missing. + yield* provideCacheFs( + fileSystem + .makeDirectory(overlayDir, { recursive: true }) + .pipe(Effect.andThen(writeOverlay({}))), + ); + yield* Effect.addFinalizer(() => fileSystem.remove(overlayPath).pipe(Effect.ignore)); const launch = buildPiRpcLaunch({ launchArgs: resolvedLaunchArgs.args, environment: options.environment, mcpSession, extensionPath, + configOverlay: { path: overlayPath, platform: yield* HostProcess.Platform }, runtimeMode: input.runtimePolicy.runtimeMode, }); const scopedLaunch = yield* agentScope.wrap({ @@ -547,6 +578,15 @@ export const makePiAdapterV2 = Effect.fn("makePiAdapterV2")(function* ( }), ); let appliedModel: string | null = null; + // Tracked apart from the applied model: a failed set_model leaves the overlay already rewritten. + let overlayPreset: string | null = null; + // Preset slug -> main model slug, so context lookups work before a preset's first turn. + let presetMainModels: Map | null = null; + let appliedPreset: { + readonly slug: string; + readonly mainModel: string; + readonly thinking: string | undefined; + } | null = null; let appliedThinking: string | null = null; let appliedSessionName: string | null = null; const outOfTurnExtensionErrors: Array = []; @@ -2148,6 +2188,7 @@ export const makePiAdapterV2 = Effect.fn("makePiAdapterV2")(function* ( // Failed lifecycle operations can still change native sessions; invalidate bindings and defaults. threadState = null; appliedModel = null; + appliedPreset = null; appliedThinking = null; appliedSessionName = null; baselineModel = null; @@ -2174,6 +2215,30 @@ export const makePiAdapterV2 = Effect.fn("makePiAdapterV2")(function* ( } } contextWindow = rememberModelContextWindow(recordField(stateData, "model")); + // Plain Pi has no `config` subcommand; a preset selection means the binary is omp. + if ( + presetMainModels === null && + piPresetNameFromSlug(threadInput.modelSelection.model) !== undefined + ) { + const presets = yield* readOmpModelPresets({ + binaryPath: options.settings.binaryPath || "pi", + environment: options.environment, + cwd, + launchArgs: resolvedLaunchArgs.args, + }).pipe( + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + Effect.orElseSucceed(() => []), + ); + const knownModels = new Set(modelContextWindows.keys()); + presetMainModels = new Map( + presets.flatMap((preset) => { + const parsed = parseOmpModelSelector(preset.modelRoles.default ?? "", knownModels); + return parsed === null + ? [] + : [[piPresetSlug(preset.name), `${parsed.provider}/${parsed.modelId}`] as const]; + }), + ); + } // Capture each baseline before applying selections so our own choices cannot become Pi defaults. if (baselineModel === null && appliedModel === null) { const stateModel = recordField(stateData, "model"); @@ -2242,7 +2307,87 @@ export const makePiAdapterV2 = Effect.fn("makePiAdapterV2")(function* ( return providerThread; }); + const writeSessionOverlay = (overlay: Record) => + writeOverlay(overlay).pipe(Effect.mapError((cause) => protocolError(cause.message))); + const applyPresetSelection = Effect.fnUntraced(function* ( + modelSelection: ModelSelection, + presetName: string, + ) { + if (overlayPreset !== presetName || modelSelection.model !== appliedModel) { + const resolved = yield* resolveOmpPreset({ + binaryPath: options.settings.binaryPath || "pi", + environment: options.environment, + cwd, + launchArgs: resolvedLaunchArgs.args, + name: presetName, + }).pipe( + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + Effect.mapError((cause) => protocolError(cause.message)), + ); + const knownModels = piModelInventorySlugs( + yield* request({ type: "get_available_models" }), + ); + const mainSelector = resolved.preset.modelRoles.default ?? ""; + const parsed = parseOmpModelSelector(mainSelector, knownModels); + if (parsed === null) { + return yield* protocolError( + `OhMyPi preset '${presetName}' main model '${mainSelector}' must use provider/model format`, + ); + } + // shortcut: omp reloads a rewritten overlay ~200ms later, so role models switch from the + // next role lookup; the main model and thinking switch now over RPC. Wait for a reload + // signal if omp ever exposes one. + yield* writeSessionOverlay(resolved.overlay); + overlayPreset = presetName; + const selectedModel = yield* request({ + type: "set_model", + provider: parsed.provider, + modelId: parsed.modelId, + }); + contextWindow = rememberModelContextWindow(selectedModel); + presetMainModels?.set(modelSelection.model, `${parsed.provider}/${parsed.modelId}`); + appliedPreset = { + slug: modelSelection.model, + mainModel: `${parsed.provider}/${parsed.modelId}`, + // Like omp, a preset without a level falls back to the configured one. + thinking: + parsed.thinking ?? + resolved.preset.defaultThinkingLevel ?? + resolved.configuredThinkingLevel, + }; + // set_model may change the level; a non-null unknown still lets Pi default restore it. + appliedThinking = UNKNOWN_PI_THINKING; + appliedModel = modelSelection.model; + const updatedAt = yield* DateTime.now; + sessionEntity = { ...sessionEntity, model: modelSelection.model, updatedAt }; + yield* emit({ + type: "provider_session.updated", + driver: PI_PROVIDER, + providerSession: sessionEntity, + }); + } + const thinking = + getModelSelectionStringOptionValue(modelSelection, "thinking") ?? appliedPreset?.thinking; + if ( + thinking !== undefined && + thinking !== appliedThinking && + (PI_THINKING_LEVELS.has(thinking) || thinking === OMP_AUTO_THINKING) + ) { + yield* request({ type: "set_thinking_level", level: thinking }); + appliedThinking = thinking; + } + }); + const applySelection = Effect.fnUntraced(function* (modelSelection: ModelSelection) { + const selectedPreset = piPresetNameFromSlug(modelSelection.model); + if (selectedPreset !== undefined) { + return yield* applyPresetSelection(modelSelection, selectedPreset); + } + if (overlayPreset !== null) { + yield* writeSessionOverlay({}); + overlayPreset = null; + appliedPreset = null; + } const thinking = getModelSelectionStringOptionValue(modelSelection, "thinking"); if (modelSelection.model === PI_INHERIT_MODEL_SLUG) { if (appliedModel !== null && baselineModel !== null) { @@ -2344,7 +2489,11 @@ export const makePiAdapterV2 = Effect.fn("makePiAdapterV2")(function* ( ? baselineModel === null ? undefined : `${baselineModel.provider}/${baselineModel.modelId}` - : selection.model; + : piPresetNameFromSlug(selection.model) !== undefined + ? appliedPreset?.slug === selection.model + ? appliedPreset.mainModel + : presetMainModels?.get(selection.model) + : selection.model; return slug === undefined ? undefined : modelContextWindows.get(slug); }, ensureThread: (threadInput) => @@ -2902,6 +3051,7 @@ export const makePiAdapterV2 = Effect.fn("makePiAdapterV2")(function* ( ); lastNativeThreadId = forkSessionFile; appliedModel = null; + appliedPreset = null; appliedThinking = null; appliedSessionName = null; lastKnownLeaf = leafId; diff --git a/packages/provider-pi/src/server/mcpInjection.test.ts b/packages/provider-pi/src/server/mcpInjection.test.ts index 26d01b983039e..dda0ce4d59e85 100644 --- a/packages/provider-pi/src/server/mcpInjection.test.ts +++ b/packages/provider-pi/src/server/mcpInjection.test.ts @@ -22,6 +22,20 @@ const mcpSession = { }; describe("pi T3 MCP injection", () => { + it("lists T3's OhMyPi overlay after inherited ones so it wins", () => { + const launch = buildPiRpcLaunch({ + launchArgs: [], + environment: { PI_CONFIG_FILES: "/home/user/work.yml" }, + mcpSession: undefined, + extensionPath: undefined, + configOverlay: { path: "/tmp/cache/pi-presets/session.json", platform: "linux" }, + }); + assert.equal( + launch.env.PI_CONFIG_FILES, + "/home/user/work.yml:/tmp/cache/pi-presets/session.json", + ); + }); + it("always adds the permission bridge and configures MCP when available", () => { const resolvedArgs = resolvePiLaunchArgs( "--extension=/home/user/.pi/agent/extensions/demo.ts --session-dir=/tmp/pi-sessions --provider=anthropic --model=claude-sonnet --tools='' --name=-review --extension-flag=kept", diff --git a/packages/provider-pi/src/server/mcpInjection.ts b/packages/provider-pi/src/server/mcpInjection.ts index 19165a2ccbeee..6c83f0340a1d4 100644 --- a/packages/provider-pi/src/server/mcpInjection.ts +++ b/packages/provider-pi/src/server/mcpInjection.ts @@ -243,6 +243,8 @@ export function buildPiRpcLaunch(input: { readonly mcpSession: McpProviderSessionConfig | undefined; readonly extensionPath: string | undefined; readonly sessionPath?: string | undefined; + /** OhMyPi config overlay, passed in `PI_CONFIG_FILES`, which plain Pi ignores. */ + readonly configOverlay?: { readonly path: string; readonly platform: NodeJS.Platform }; readonly ephemeral?: boolean; readonly disableExtensions?: boolean; readonly disableTools?: boolean; @@ -285,6 +287,12 @@ export function buildPiRpcLaunch(input: { delete environment[T3_MCP_URL_ENV]; delete environment[T3_MCP_BEARER_ENV]; delete environment[T3_PI_MCP_EXTENSION_PATH_ENV]; + if (input.configOverlay !== undefined) { + // Later overlays win, so T3's goes after any the user already passes. + environment.PI_CONFIG_FILES = [environment.PI_CONFIG_FILES, input.configOverlay.path] + .filter(Boolean) + .join(input.configOverlay.platform === "win32" ? ";" : ":"); + } return { args, diff --git a/packages/provider-pi/src/server/presets.test.ts b/packages/provider-pi/src/server/presets.test.ts new file mode 100644 index 0000000000000..338160741dd93 --- /dev/null +++ b/packages/provider-pi/src/server/presets.test.ts @@ -0,0 +1,31 @@ +import { assert, describe, it } from "@effect/vitest"; + +import { parseOmpModelSelector } from "./presets.ts"; + +describe("parseOmpModelSelector", () => { + it("reads a known thinking suffix and keeps slashes in the model id", () => { + assert.deepEqual(parseOmpModelSelector("loem/z-ai/glm-5.2:high", new Set()), { + provider: "loem", + modelId: "z-ai/glm-5.2", + thinking: "high", + }); + }); + + it("keeps a suffix that is part of an exact model id, as omp does", () => { + assert.deepEqual(parseOmpModelSelector("openrouter/glm:max", new Set(["openrouter/glm:max"])), { + provider: "openrouter", + modelId: "glm:max", + thinking: undefined, + }); + }); + + it("expands an unambiguous thinking abbreviation, as omp does", () => { + assert.equal(parseOmpModelSelector("openai/gpt:hi", new Set())?.thinking, "high"); + assert.equal(parseOmpModelSelector("openai/gpt:m", new Set())?.modelId, "gpt:m"); + }); + + it("rejects aliases and bare model names it cannot hand to set_model", () => { + assert.isNull(parseOmpModelSelector("@slow", new Set())); + assert.isNull(parseOmpModelSelector("claude-opus:high", new Set())); + }); +}); diff --git a/packages/provider-pi/src/server/presets.ts b/packages/provider-pi/src/server/presets.ts new file mode 100644 index 0000000000000..cae1a879aaf41 --- /dev/null +++ b/packages/provider-pi/src/server/presets.ts @@ -0,0 +1,308 @@ +import type { ServerProviderModel } from "@t3tools/contracts"; +import { resolveSpawnCommand } from "@t3tools/shared/shell"; +import { spawnAndCollect } from "@t3tools/provider-core/server/snapshotProbe"; +import * as Effect from "effect/Effect"; +import * as Predicate from "effect/Predicate"; +import * as Schema from "effect/Schema"; +import * as ChildProcess from "effect/process/ChildProcess"; + +import { PiRpcError, PiRpcTimeoutError } from "./rpc.ts"; +import { + EMPTY_PI_MODEL_CAPABILITIES, + thinkingCapabilitiesForPiModel, +} from "./thinkingCapabilities.ts"; + +// OhMyPi (`omp`) model presets name a whole set of role models; T3 lists them as models. +const PI_PRESET_SLUG_PREFIX = "preset:"; +const PRESET_NAME_PATTERN = /^[a-zA-Z][\w-]*$/; +const OMP_CONFIG_TIMEOUT_MS = 10_000; +const OMP_THINKING_LEVELS = ["inherit", "off", "minimal", "low", "medium", "high", "xhigh", "max"]; + +/** omp's thinking selector parsing: exact, or an unambiguous prefix of two or more characters. */ +function parseOmpThinkingLevel(value: string): string | undefined { + if (OMP_THINKING_LEVELS.includes(value)) return value; + if (value.length < 2) return undefined; + const matches = OMP_THINKING_LEVELS.filter((level) => level.startsWith(value)); + return matches.length === 1 ? matches[0] : undefined; +} + +/** omp's rule for a preset's `defaultThinkingLevel`: `auto` or an effort, never `off`/`inherit`. */ +function parseOmpDefaultThinkingLevel(value: string): string | undefined { + if (value === "auto") return value; + const level = parseOmpThinkingLevel(value); + return level === "off" || level === "inherit" ? undefined : level; +} + +const decodeOmpConfigOutput = Schema.decodeUnknownEffect( + Schema.fromJsonString(Schema.Struct({ value: Schema.optional(Schema.Unknown) })), +); +export const encodeOmpConfigOverlay = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown)); + +export const OMP_AUTO_THINKING = "auto"; + +export interface OmpModelPreset { + readonly name: string; + readonly modelRoles: Readonly>; + readonly defaultThinkingLevel: string | undefined; +} + +export function piPresetSlug(name: string): string { + return `${PI_PRESET_SLUG_PREFIX}${name}`; +} + +export function piPresetNameFromSlug(slug: string): string | undefined { + return slug.startsWith(PI_PRESET_SLUG_PREFIX) + ? slug.slice(PI_PRESET_SLUG_PREFIX.length) + : undefined; +} + +export function isOmpVersionOutput(output: string): boolean { + return /^omp\/\d/m.test(output); +} + +function isStringRecord(input: unknown): input is Record { + return Predicate.isObject(input) && !Array.isArray(input); +} + +/** Mirrors omp's own preset validation; hand-edited config can hold anything. */ +function parseOmpModelPresets(value: unknown): ReadonlyArray { + if (!isStringRecord(value)) return []; + const presets: Array = []; + for (const [name, raw] of Object.entries(value)) { + if (!PRESET_NAME_PATTERN.test(name) || !isStringRecord(raw)) continue; + const roles = raw.modelRoles; + if (!isStringRecord(roles)) continue; + const modelRoles: Record = {}; + let valid = true; + for (const [role, selector] of Object.entries(roles)) { + if (typeof selector !== "string" || selector.trim() === "") { + valid = false; + break; + } + modelRoles[role] = selector; + } + const rawLevel = raw.defaultThinkingLevel; + const level = typeof rawLevel === "string" ? parseOmpDefaultThinkingLevel(rawLevel) : undefined; + // T3 sets the main model from `default`; without one omp would pick a model T3 cannot see. + if (!valid || modelRoles.default === undefined || (rawLevel !== undefined && !level)) { + continue; + } + presets.push({ name, modelRoles, defaultThinkingLevel: level }); + } + return presets.toSorted((left, right) => left.name.localeCompare(right.name)); +} + +/** `provider/id` slugs from a Pi RPC `get_available_models` response. */ +export function piModelInventorySlugs(modelInventory: unknown): ReadonlySet { + const models = isStringRecord(modelInventory) ? modelInventory.models : undefined; + if (!Array.isArray(models)) return new Set(); + return new Set( + models.flatMap((model) => + isStringRecord(model) && typeof model.provider === "string" && typeof model.id === "string" + ? [`${model.provider}/${model.id}`] + : [], + ), + ); +} + +/** + * Splits an omp role selector (`provider/model[:level]`) into what Pi RPC `set_model` takes. + * Like omp, an exact model id wins over reading its last `:segment` as a thinking level. + * shortcut: aliases (`@slow`) and bare model patterns need omp's fuzzy resolver, so they are + * reported as unsupported; add an RPC-side resolution if presets commonly use them. + */ +export function parseOmpModelSelector( + selector: string, + knownModels: ReadonlySet, +): { + readonly provider: string; + readonly modelId: string; + readonly thinking: string | undefined; +} | null { + const colon = selector.lastIndexOf(":"); + const suffix = colon === -1 ? undefined : selector.slice(colon + 1); + const thinking = + suffix !== undefined && !knownModels.has(selector) ? parseOmpThinkingLevel(suffix) : undefined; + const model = thinking === undefined ? selector : selector.slice(0, colon); + const separator = model.indexOf("/"); + if (model.startsWith("@") || separator <= 0 || separator === model.length - 1) return null; + return { provider: model.slice(0, separator), modelId: model.slice(separator + 1), thinking }; +} + +/** The level a preset starts at: the `default` selector's suffix wins over `defaultThinkingLevel`. */ +function ompPresetThinkingLevel( + preset: OmpModelPreset, + knownModels: ReadonlySet, +): string | undefined { + const selector = preset.modelRoles.default; + const fromSelector = + selector === undefined ? undefined : parseOmpModelSelector(selector, knownModels)?.thinking; + return fromSelector ?? preset.defaultThinkingLevel; +} + +/** + * `--config` overlay content for a preset. Roles the preset leaves out are written as `null` + * tombstones so global roles do not leak in, matching omp's own `/modelpreset switch`. + */ +function ompPresetOverlay( + preset: OmpModelPreset, + effectiveRoles: Readonly>, +): Record { + const modelRoles: Record = {}; + for (const role of Object.keys(effectiveRoles)) modelRoles[role] = null; + Object.assign(modelRoles, preset.modelRoles); + return { + modelRoles, + ...(preset.defaultThinkingLevel === undefined + ? {} + : { defaultThinkingLevel: preset.defaultThinkingLevel }), + }; +} + +/** Pi models have no `auto` level; a preset that uses omp's `auto` offers it as its default. */ +function presetThinkingCapabilities(rawModel: unknown, level: string | undefined) { + const capabilities = thinkingCapabilitiesForPiModel(rawModel, level); + if (level !== OMP_AUTO_THINKING) return capabilities; + return { + optionDescriptors: capabilities.optionDescriptors?.map((descriptor) => + descriptor.type === "select" + ? { + ...descriptor, + options: [ + { id: OMP_AUTO_THINKING, label: "Auto", isDefault: true }, + ...descriptor.options, + ], + } + : descriptor, + ), + }; +} + +export function ompPresetModels( + presets: ReadonlyArray, + modelInventory: unknown, +): ReadonlyArray { + const inventory = isStringRecord(modelInventory) ? modelInventory.models : undefined; + const rawModels: ReadonlyArray = Array.isArray(inventory) ? inventory : []; + const knownModels = piModelInventorySlugs(modelInventory); + return presets.map((preset) => { + const selector = preset.modelRoles.default; + const parsed = selector === undefined ? null : parseOmpModelSelector(selector, knownModels); + const rawModel = + parsed === null + ? undefined + : rawModels.find( + (model) => + isStringRecord(model) && + model.provider === parsed.provider && + model.id === parsed.modelId, + ); + return { + slug: piPresetSlug(preset.name), + name: preset.name, + subProvider: "Preset", + isCustom: false, + capabilities: + rawModel === undefined + ? EMPTY_PI_MODEL_CAPABILITIES + : presetThinkingCapabilities(rawModel, ompPresetThinkingLevel(preset, knownModels)), + }; + }); +} + +interface OmpConfigReadInput { + readonly binaryPath: string; + readonly environment: NodeJS.ProcessEnv; + readonly cwd: string | undefined; + /** Resolved Pi launch args; their `--profile` selects which omp config is read. */ + readonly launchArgs: ReadonlyArray; +} + +function ompProfileArgs(launchArgs: ReadonlyArray): ReadonlyArray { + const index = launchArgs.findIndex((arg) => arg === "--profile" || arg.startsWith("--profile=")); + if (index === -1) return []; + const arg = launchArgs[index]!; + if (arg !== "--profile") return [arg]; + const value = launchArgs[index + 1]; + return value === undefined ? [] : [arg, value]; +} + +const readOmpConfigValue = Effect.fn("PiPresets.readOmpConfigValue")( + function* (input: OmpConfigReadInput & { readonly key: string }) { + // omp only reads `--profile` before the subcommand. + const args = [...ompProfileArgs(input.launchArgs), "config", "get", input.key, "--json"]; + const spawnCommand = yield* resolveSpawnCommand(input.binaryPath, args, { + env: input.environment, + }); + const result = yield* spawnAndCollect( + input.binaryPath, + ChildProcess.make(spawnCommand.command, spawnCommand.args, { + env: input.environment, + shell: spawnCommand.shell, + ...(input.cwd === undefined ? {} : { cwd: input.cwd }), + }), + ); + const detail = `\`omp config get ${input.key}\``; + if (result.code !== 0) { + return yield* new PiRpcError({ + operation: "omp_config", + detail: `${detail} exited ${result.code}`, + }); + } + const parsed = yield* decodeOmpConfigOutput(result.stdout).pipe( + Effect.mapError( + (cause) => + new PiRpcError({ + operation: "omp_config", + detail: `${detail} printed invalid JSON`, + cause, + }), + ), + ); + return parsed.value; + }, + Effect.timeoutOrElse({ + duration: OMP_CONFIG_TIMEOUT_MS, + orElse: () => + Effect.fail( + new PiRpcTimeoutError({ operation: "omp_config", timeoutMs: OMP_CONFIG_TIMEOUT_MS }), + ), + }), +); + +/** + * shortcut: `omp config get` returns presets deep-merged across config layers, while omp takes a + * same-name preset whole from its highest layer; read the layer files if that ever matters. + */ +export const readOmpModelPresets = (input: OmpConfigReadInput) => + readOmpConfigValue({ ...input, key: "modelPresets" }).pipe(Effect.map(parseOmpModelPresets)); + +/** Resolves a preset by name and builds its overlay against the workspace's current roles. */ +export const resolveOmpPreset = Effect.fn("PiPresets.resolveOmpPreset")(function* ( + input: OmpConfigReadInput & { readonly name: string }, +) { + const [presets, roles, configuredThinking] = yield* Effect.all( + [ + readOmpModelPresets(input), + readOmpConfigValue({ ...input, key: "modelRoles" }), + readOmpConfigValue({ ...input, key: "defaultThinkingLevel" }), + ], + { concurrency: "unbounded" }, + ); + const preset = presets.find((candidate) => candidate.name === input.name); + if (preset === undefined) { + return yield* new PiRpcError({ + operation: "omp_preset", + detail: `OhMyPi model preset '${input.name}' was not found, is malformed, or has no main (default) model`, + }); + } + return { + preset, + overlay: ompPresetOverlay(preset, isStringRecord(roles) ? roles : {}), + // What omp falls back to when the preset sets no level. + configuredThinkingLevel: + typeof configuredThinking === "string" + ? parseOmpDefaultThinkingLevel(configuredThinking) + : undefined, + }; +}); diff --git a/packages/provider-pi/src/server/status.test.ts b/packages/provider-pi/src/server/status.test.ts index cee6442fc9642..0d4782f6cd38f 100644 --- a/packages/provider-pi/src/server/status.test.ts +++ b/packages/provider-pi/src/server/status.test.ts @@ -1,11 +1,14 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, describe, it } from "@effect/vitest"; import * as Cause from "effect/Cause"; +import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; import * as Queue from "effect/Queue"; import * as Schema from "effect/Schema"; import * as Sink from "effect/Sink"; import * as Stream from "effect/Stream"; +import * as TestClock from "effect/testing/TestClock"; import * as ChildProcess from "effect/process/ChildProcess"; import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; @@ -52,7 +55,19 @@ function piProbeSpawner(version: string) { }); } -const piDiscoverySpawner = (inventory: unknown) => +const piDiscoverySpawner = ( + inventory: unknown, + omp?: { + readonly modelPresets: unknown; + readonly configCalls: Array; + readonly stall?: Deferred.Deferred; + /** The models reply is held from `requested` until the test completes `release`. */ + readonly slowModels?: { + readonly requested: Deferred.Deferred; + readonly release: Deferred.Deferred; + }; + }, +) => Effect.gen(function* () { const stdout = yield* Queue.unbounded(); let stdinBuffer = ""; @@ -66,6 +81,10 @@ const piDiscoverySpawner = (inventory: unknown) => stdinBuffer = stdinBuffer.slice(newline + 1); if (line.length === 0) continue; const request = decodeRpcRequest(line); + if (request.type === "get_available_models" && omp?.slowModels !== undefined) { + yield* Deferred.succeed(omp.slowModels.requested, undefined); + yield* Deferred.await(omp.slowModels.release); + } const data = request.type === "get_available_models" ? inventory @@ -83,9 +102,26 @@ const piDiscoverySpawner = (inventory: unknown) => ); return ChildProcessSpawner.make((command) => { const args = ChildProcess.isStandardCommand(command) ? command.args : []; + if (args.includes("config")) { + omp?.configCalls.push(args.join(" ")); + if (omp?.stall !== undefined) { + return Deferred.succeed(omp.stall, undefined).pipe( + Effect.as( + ChildProcessSpawner.makeHandle({ + ...processHandle({}), + exitCode: Effect.never, + stdout: Stream.never, + }), + ), + ); + } + return Effect.succeed( + processHandle({ stdout: encodeJsonLine({ value: omp?.modelPresets }) }), + ); + } return Effect.succeed( args.includes("--version") - ? processHandle({ stdout: "pi 0.84.3\n" }) + ? processHandle({ stdout: omp === undefined ? "pi 0.84.3\n" : "omp/18.5.1\n" }) : ChildProcessSpawner.makeHandle({ ...processHandle({}), exitCode: Effect.never, @@ -146,6 +182,85 @@ describe("PiProvider", () => { }).pipe(Effect.provide(NodeServices.layer)), ); + it.effect("lists OhMyPi presets as models that start at the preset's thinking level", () => + Effect.gen(function* () { + const configCalls: Array = []; + const spawner = yield* piDiscoverySpawner( + { + models: [ + { provider: "anthropic", id: "claude-opus", reasoning: true }, + { provider: "openai", id: "gpt" }, + ], + }, + { + configCalls, + modelPresets: { + OpenAI: { modelRoles: { default: "openai/gpt" }, defaultThinkingLevel: "min" }, + Anthropic: { modelRoles: { default: "anthropic/claude-opus:low" } }, + Broken: { modelRoles: { default: "" } }, + NumericThinking: { modelRoles: { default: "openai/gpt" }, defaultThinkingLevel: 42 }, + UnknownThinking: { modelRoles: { default: "openai/gpt" }, defaultThinkingLevel: "x" }, + NoMainModel: { modelRoles: { task: "openai/gpt" } }, + }, + }, + ); + const snapshot = yield* checkPiProviderStatus({ + ...settings, + binaryPath: "omp", + launchArgs: "--profile work", + }).pipe(Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner)); + assert.deepEqual(configCalls, ["--profile work config get modelPresets --json"]); + assert.deepEqual( + snapshot.models.map((model) => [model.slug, model.subProvider]), + [ + ["default", undefined], + ["preset:Anthropic", "Preset"], + ["preset:OpenAI", "Preset"], + ["anthropic/claude-opus", "anthropic"], + ["openai/gpt", "openai"], + ], + ); + const thinking = snapshot.models[1]?.capabilities?.optionDescriptors?.[0]; + assert.equal(thinking?.id, "thinking"); + assert.deepEqual( + thinking?.type === "select" + ? thinking.options.filter((option) => option.isDefault).map((option) => option.id) + : [], + ["low"], + ); + }).pipe(Effect.provide(NodeServices.layer)), + ); + + it.effect("keeps discovered models when OhMyPi preset discovery stalls after slow RPC", () => + Effect.gen(function* () { + const stall = yield* Deferred.make(); + const slowModels = { + requested: yield* Deferred.make(), + release: yield* Deferred.make(), + }; + const spawner = yield* piDiscoverySpawner( + { models: [{ provider: "openai", id: "gpt" }] }, + { configCalls: [], modelPresets: {}, stall, slowModels }, + ); + const fiber = yield* checkPiProviderStatus({ ...settings, binaryPath: "omp" }).pipe( + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + Effect.forkChild, + ); + // RPC discovery uses 25 of its 30 seconds before the preset read starts. + yield* Deferred.await(slowModels.requested); + yield* TestClock.adjust("25 seconds"); + yield* Deferred.succeed(slowModels.release, undefined); + yield* Deferred.await(stall); + yield* TestClock.adjust("10 seconds"); + const snapshot = yield* Fiber.join(fiber); + assert.equal(snapshot.auth.status, "authenticated"); + assert.deepEqual( + snapshot.models.map((model) => model.slug), + ["default", "openai/gpt"], + ); + }).pipe(Effect.provide(NodeServices.layer)), + ); + // regression: #18498 a malformed model inventory must not restore stale Pi models it.effect.each([ ["missing models", {}], diff --git a/packages/provider-pi/src/server/status.ts b/packages/provider-pi/src/server/status.ts index 3030200c245a3..c7fcf243c911d 100644 --- a/packages/provider-pi/src/server/status.ts +++ b/packages/provider-pi/src/server/status.ts @@ -42,6 +42,7 @@ import { EMPTY_PI_MODEL_CAPABILITIES, thinkingCapabilitiesForPiModel, } from "./thinkingCapabilities.ts"; +import { isOmpVersionOutput, ompPresetModels, readOmpModelPresets } from "./presets.ts"; import { parsePiDiscoveredCommands, withPiBuiltinSlashCommands, @@ -73,6 +74,7 @@ const PI_DEFAULT_MODEL: ServerProviderModel = { interface PiDiscovery extends PiDiscoveredCommands { readonly models: ReadonlyArray; + readonly modelInventory: unknown; readonly authenticated: boolean; } @@ -160,7 +162,7 @@ const discoverPiViaRpc = Effect.fn("PiProviderStatus.discoverPiViaRpc")(function piSettings: PiSettings, environment: NodeJS.ProcessEnv, launchArgs: ReadonlyArray, - cwd?: string, + cwd: string | undefined, ) { const connection = yield* makePiDiscoveryConnection(piSettings, environment, launchArgs, cwd); const stateData = yield* connection.request({ type: "get_state" }); @@ -172,6 +174,7 @@ const discoverPiViaRpc = Effect.fn("PiProviderStatus.discoverPiViaRpc")(function const { slashCommands, skills } = parsePiDiscoveredCommands(commandsData); return { models: discoveredModels, + modelInventory: modelsData, slashCommands: withPiBuiltinSlashCommands(slashCommands), skills, authenticated: discoveredModels.length > 0, @@ -432,7 +435,25 @@ export const checkPiProviderStatus = Effect.fn("checkPiProviderStatus")(function } const discovery = discoveryExit.value.value; - const models = piModelsFromSettings(piSettings.customModels, discovery.models); + // Outside the RPC deadline so a slow read only loses the presets. Plain Pi has no `config` + // subcommand, so only binaries that identify as omp are asked. + const presets = isOmpVersionOutput(versionOutput.stdout) + ? yield* readOmpModelPresets({ + binaryPath: piSettings.binaryPath || "pi", + environment, + cwd, + launchArgs: resolvedLaunchArgs.args, + }).pipe( + Effect.tapError((error) => + Effect.logWarning("OhMyPi model preset discovery failed.", { errorTag: error._tag }), + ), + Effect.orElseSucceed(() => []), + ) + : []; + const models = piModelsFromSettings(piSettings.customModels, [ + ...ompPresetModels(presets, discovery.modelInventory), + ...discovery.models, + ]); return buildServerProvider({ presentation: PI_PRESENTATION, enabled: piSettings.enabled, diff --git a/packages/provider-pi/src/server/textGeneration.test.ts b/packages/provider-pi/src/server/textGeneration.test.ts index f4f9300c73113..033d3d5afadd8 100644 --- a/packages/provider-pi/src/server/textGeneration.test.ts +++ b/packages/provider-pi/src/server/textGeneration.test.ts @@ -7,6 +7,7 @@ import * as Queue from "effect/Queue"; import * as Schema from "effect/Schema"; import * as Sink from "effect/Sink"; import * as Stream from "effect/Stream"; +import * as ChildProcess from "effect/process/ChildProcess"; import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; import { makePiTextGeneration } from "./textGeneration.ts"; @@ -14,7 +15,7 @@ import { makePiTextGeneration } from "./textGeneration.ts"; const decodeJsonLine = Schema.decodeUnknownSync(Schema.fromJsonString(Schema.Unknown)); const encodeJsonLine = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown)); -const makeFakePi = (reply: string, setModelError?: string) => +const makeFakePi = (reply: string, setModelError?: string, modelPresets?: unknown) => Effect.gen(function* () { const stdout = yield* Queue.unbounded(); const received: Array> = []; @@ -42,22 +43,38 @@ const makeFakePi = (reply: string, setModelError?: string) => } } }); - const spawner = ChildProcessSpawner.make(() => + const spawner = ChildProcessSpawner.make((command) => Effect.succeed( - ChildProcessSpawner.makeHandle({ - // Outside the valid pid range, so PiRpc's process-group kill never lands. - pid: ChildProcessSpawner.ProcessId(999_999_999), - exitCode: Effect.never, - isRunning: Effect.succeed(true), - kill: () => Effect.void, - unref: Effect.succeed(Effect.void), - stdin: Sink.forEach(onStdin), - stdout: Stream.fromQueue(stdout), - stderr: Stream.empty, - all: Stream.empty, - getInputFd: () => Sink.drain, - getOutputFd: () => Stream.empty, - }), + ChildProcess.isStandardCommand(command) && command.args.includes("config") + ? ChildProcessSpawner.makeHandle({ + pid: ChildProcessSpawner.ProcessId(999_999_999), + exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(0)), + isRunning: Effect.succeed(false), + kill: () => Effect.void, + unref: Effect.succeed(Effect.void), + stdin: Sink.drain, + stdout: Stream.succeed( + new TextEncoder().encode(encodeJsonLine({ value: modelPresets })), + ), + stderr: Stream.empty, + all: Stream.empty, + getInputFd: () => Sink.drain, + getOutputFd: () => Stream.empty, + }) + : ChildProcessSpawner.makeHandle({ + // Outside the valid pid range, so PiRpc's process-group kill never lands. + pid: ChildProcessSpawner.ProcessId(999_999_999), + exitCode: Effect.never, + isRunning: Effect.succeed(true), + kill: () => Effect.void, + unref: Effect.succeed(Effect.void), + stdin: Sink.forEach(onStdin), + stdout: Stream.fromQueue(stdout), + stderr: Stream.empty, + all: Stream.empty, + getInputFd: () => Sink.drain, + getOutputFd: () => Stream.empty, + }), ), ); return { spawner, received }; @@ -88,3 +105,33 @@ it.effect("names the model and the disabled extensions when Pi cannot load it", assert.isUndefined(pi.received.find((record) => record["type"] === "prompt")); }), ); + +it.effect("generates with an OhMyPi preset's main model", () => + Effect.gen(function* () { + const pi = yield* makeFakePi( + encodeJsonLine({ subject: "Update README", body: "" }), + undefined, + { + Cheap: { modelRoles: { default: "loem/z-ai/glm-5.2:high", task: "loem/kimi-k3" } }, + }, + ); + const textGeneration = yield* makePiTextGeneration( + { enabled: true, binaryPath: "omp", launchArgs: "", customModels: [] }, + {}, + ).pipe(Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, pi.spawner)); + + const generated = 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"), "preset:Cheap"), + }); + + assert.equal(generated.subject, "Update README"); + + const setModel = pi.received.find((record) => record["type"] === "set_model"); + assert.equal(setModel?.["provider"], "loem"); + assert.equal(setModel?.["modelId"], "z-ai/glm-5.2"); + }), +); diff --git a/packages/provider-pi/src/server/textGeneration.ts b/packages/provider-pi/src/server/textGeneration.ts index 218a6300a35e1..77514a5d32095 100644 --- a/packages/provider-pi/src/server/textGeneration.ts +++ b/packages/provider-pi/src/server/textGeneration.ts @@ -9,6 +9,12 @@ import type { PiSettings } from "../settings.ts"; import { makePiRpcConnection, parsePiModelSlug } from "./rpc.ts"; import { buildPiRpcLaunch, resolvePiLaunchArgs } from "./mcpInjection.ts"; +import { + parseOmpModelSelector, + piModelInventorySlugs, + piPresetNameFromSlug, + readOmpModelPresets, +} from "./presets.ts"; import * as TextGenerationOperations from "@t3tools/provider-core/server/textGenerationOperations"; const PI_TIMEOUT_MS = 180_000; @@ -21,6 +27,43 @@ export const makePiTextGeneration = Effect.fn("makePiTextGeneration")(function* ) { const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const resolvePresetMainModel = Effect.fnUntraced(function* ( + operation: TextGenerationError["operation"], + cwd: string, + launchArgs: ReadonlyArray, + presetName: string, + listModels: Effect.Effect, + ) { + const presets = yield* readOmpModelPresets({ + binaryPath: piSettings.binaryPath || "pi", + environment, + cwd, + launchArgs, + }).pipe( + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + Effect.mapError( + (cause) => new TextGenerationError({ operation, detail: cause.message, cause }), + ), + ); + const preset = presets.find((candidate) => candidate.name === presetName); + if (preset === undefined) { + return yield* new TextGenerationError({ + operation, + detail: `OhMyPi model preset '${presetName}' was not found or is malformed.`, + }); + } + const selector = preset.modelRoles.default ?? ""; + const knownModels = piModelInventorySlugs(yield* listModels); + const parsed = parseOmpModelSelector(selector, knownModels); + if (parsed === null) { + return yield* new TextGenerationError({ + operation, + detail: `OhMyPi preset '${presetName}' main model '${selector}' must use provider/model format.`, + }); + } + return `${parsed.provider}/${parsed.modelId}`; + }); + const runPiJson: TextGenerationOperations.Runner = Effect.fn("PiTextGeneration.runPiJson")( function* (request) { const { operation, cwd, prompt, modelSelection } = request; @@ -49,13 +92,27 @@ export const makePiTextGeneration = Effect.fn("makePiTextGeneration")(function* env: launch.env, }).pipe(Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner)); - if (modelSelection.model !== "default") { + // Text generation runs one prompt with no subagents, so a preset only contributes its main model. + const presetName = piPresetNameFromSlug(modelSelection.model); + const model = + presetName === undefined + ? modelSelection.model + : yield* resolvePresetMainModel( + operation, + cwd, + resolvedLaunchArgs.args, + presetName, + connection + .request({ type: "get_available_models" }) + .pipe(Effect.orElseSucceed(() => undefined)), + ); + if (model !== "default") { // Reject unusable custom slugs; silently using Pi's default would misreport the requested model. - const parsed = parsePiModelSlug(modelSelection.model); + const parsed = parsePiModelSlug(model); if (parsed === null) { return yield* new TextGenerationError({ operation, - detail: `Pi model '${modelSelection.model}' must use provider/model format.`, + detail: `Pi model '${model}' must use provider/model format.`, }); } yield* connection @@ -67,7 +124,7 @@ export const makePiTextGeneration = Effect.fn("makePiTextGeneration")(function* ? new TextGenerationError({ operation, detail: - `Pi rejected model '${modelSelection.model}': ${error.detail ?? "unknown error"}. ` + + `Pi rejected model '${model}': ${error.detail ?? "unknown error"}. ` + "If the model comes from a Pi extension or package, text generation cannot use it " + "because it runs Pi with --no-extensions. Pick a built-in Pi model or one defined " + "in Pi's models.json.",