From 53734dd4aba583335868f6711d625d49f8a4095a Mon Sep 17 00:00:00 2001 From: YJosh Date: Tue, 14 Jul 2026 14:13:48 +0200 Subject: [PATCH 1/4] Add Pi runtime context and fast model settings - Expose context-window controls when supported by the active Pi profile - Synchronize reasoning, context, and service tier before prompts - Enable Fast service for supported GPT-5.6 models --- .../src/provider/Layers/PiAdapter.test.ts | 29 +++++- apps/server/src/provider/Layers/PiAdapter.ts | 93 +++++++++++++----- .../src/provider/pi/piModelDiscovery.test.ts | 67 ++++++++++++- .../src/provider/pi/piModelDiscovery.ts | 95 +++++++++++++++++-- .../src/provider/pi/piRpcProtocol.test.ts | 18 +++- apps/server/src/provider/pi/piRpcProtocol.ts | 26 ++++- .../chat/composerProviderState.test.tsx | 38 ++++++++ docs/providers/pi.md | 4 +- 8 files changed, 332 insertions(+), 38 deletions(-) diff --git a/apps/server/src/provider/Layers/PiAdapter.test.ts b/apps/server/src/provider/Layers/PiAdapter.test.ts index 61cf6855e50..2d67c99c9eb 100644 --- a/apps/server/src/provider/Layers/PiAdapter.test.ts +++ b/apps/server/src/provider/Layers/PiAdapter.test.ts @@ -48,6 +48,7 @@ interface FakePi { const makeFakePi = Effect.fn("makeFakePi")(function* (input?: { readonly subagentsCommand?: boolean; + readonly contextCommand?: boolean; readonly fastCommand?: boolean; }) { const stdoutQueue = yield* Queue.unbounded(); @@ -100,6 +101,9 @@ const makeFakePi = Effect.fn("makeFakePi")(function* (input?: { ...(input?.subagentsCommand === false ? [] : [{ name: "subagents-rpc", source: "extension" }]), + ...(input?.contextCommand === true + ? [{ name: "context", source: "extension" }] + : []), ...(input?.fastCommand === true ? [{ name: "fast", source: "extension" }] : []), @@ -253,18 +257,19 @@ describe("makePiAdapter", () => { }).pipe(Effect.scoped, Effect.provide(TestEnv)), ); - it.effect("synchronizes Codex Fast service before the user prompt", () => + it.effect("synchronizes Pi context and Codex Fast service before the user prompt", () => Effect.gen(function* () { - const fake = yield* makeFakePi({ fastCommand: true }); + const fake = yield* makeFakePi({ contextCommand: true, fastCommand: true }); const adapter = yield* makePiAdapter(settings, { instanceId: INSTANCE }).pipe( Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, fake.spawner), ); const threadId = ThreadId.make("88888888-8888-4888-8888-888888888888"); const fastSelection: ModelSelection = { instanceId: INSTANCE, - model: "openai-codex/gpt-5.5", + model: "openai-codex/gpt-5.6-sol", options: [ { id: "reasoning", value: "off" }, + { id: "contextWindow", value: "372k" }, { id: "serviceTier", value: "priority" }, ], }; @@ -283,12 +288,16 @@ describe("makePiAdapter", () => { const thinkingIndex = fake.written.findIndex( (command) => command.type === "set_thinking_level" && command.level === "off", ); + const contextIndex = fake.written.findIndex( + (command) => command.type === "prompt" && command.message === "/context 372k", + ); const fastIndex = fake.written.findIndex( (command) => command.type === "prompt" && command.message === "/fast on", ); expect(modelIndex).toBeGreaterThanOrEqual(0); expect(thinkingIndex).toBeGreaterThan(modelIndex); - expect(fastIndex).toBeGreaterThan(thinkingIndex); + expect(contextIndex).toBeGreaterThan(thinkingIndex); + expect(fastIndex).toBeGreaterThan(contextIndex); yield* adapter.sendTurn({ threadId, @@ -297,6 +306,7 @@ describe("makePiAdapter", () => { ...fastSelection, options: [ { id: "reasoning", value: "off" }, + { id: "contextWindow", value: "auto" }, { id: "serviceTier", value: "default" }, ], }, @@ -305,6 +315,14 @@ describe("makePiAdapter", () => { (command) => command.type === "prompt" && command.message === "/fast off", ); expect(disabled.message).toBe("/fast off"); + const resetContextIndex = fake.written.findIndex( + (command) => command.type === "prompt" && command.message === "/context auto", + ); + const disableFastIndex = fake.written.findIndex( + (command) => command.type === "prompt" && command.message === "/fast off", + ); + expect(resetContextIndex).toBeGreaterThan(fastIndex); + expect(disableFastIndex).toBeGreaterThan(resetContextIndex); const userPrompt = yield* fake.takeStdinUntil( (command) => command.type === "prompt" && command.message === "Use the standard tier now", ); @@ -361,7 +379,8 @@ describe("makePiAdapter", () => { (command) => command.type === "prompt" && command.message === "first-fast-prompt", ); const fastOffIndex = fake.written.findIndex( - (command) => command.type === "prompt" && command.message === "/fast off", + (command, index) => + index > firstPromptIndex && command.type === "prompt" && command.message === "/fast off", ); const secondPromptIndex = fake.written.findIndex( (command) => command.type === "steer" && command.message === "second-standard-steer", diff --git a/apps/server/src/provider/Layers/PiAdapter.ts b/apps/server/src/provider/Layers/PiAdapter.ts index 5bba4dadabb..2e44b417041 100644 --- a/apps/server/src/provider/Layers/PiAdapter.ts +++ b/apps/server/src/provider/Layers/PiAdapter.ts @@ -56,10 +56,14 @@ import { buildPiRpcArgs, buildPiRpcEnv, extractPiAssistantText, + parsePiContextWindow, parsePiFastServiceEnabled, parsePiSubagentNotification, parsePiThinkingLevel, + PI_AUTO_CONTEXT_WINDOW, PI_CODEX_FAST_COMMAND, + PI_CONTEXT_COMMAND, + PI_CONTEXT_WINDOW_OPTION_ID, PI_SERVICE_TIER_OPTION_ID, PI_THINKING_OPTION_ID, resolvePiBinary, @@ -94,10 +98,11 @@ interface PiSessionContext { assistantItemId: ProviderItemId | undefined; assistantText: string; reasoningText: string; - /** Cached `/fast` command availability and synchronized session state. */ - fastCommandAvailable: boolean | undefined; + /** Cached extension-command availability and synchronized session state. */ + extensionCommandNames: ReadonlySet | undefined; + contextWindowSelectionKey: string | undefined; fastServiceEnabled: boolean | undefined; - /** Keeps model/thinking/service-tier synchronization atomic with its prompt. */ + /** Keeps model/thinking/context/service-tier synchronization atomic with its prompt. */ sendSemaphore: Semaphore.Semaphore; stopped: boolean; } @@ -155,12 +160,14 @@ export function splitPiModelSlug(slug: string): { provider: string; modelId: str return { provider: trimmed.slice(0, slashIndex), modelId: trimmed.slice(slashIndex + 1) }; } -function piRpcAdvertisesCommand(response: PiRpcResponse, commandName: string): boolean { +function piRpcCommandNames(response: PiRpcResponse): ReadonlySet { if (!isRecord(response.data) || !Array.isArray(response.data.commands)) { - return false; + return new Set(); } - return response.data.commands.some( - (command) => isRecord(command) && command.name === commandName, + return new Set( + response.data.commands.flatMap((command) => + isRecord(command) && typeof command.name === "string" ? [command.name] : [], + ), ); } @@ -251,14 +258,47 @@ export function makePiAdapter(piSettings: PiSettings, options?: PiAdapterLiveOpt }); }); - const syncFastService = (ctx: PiSessionContext, enabled: boolean | undefined) => + const piAdvertisesCommand = (ctx: PiSessionContext, commandName: string) => Effect.gen(function* () { - if (enabled === undefined || enabled === ctx.fastServiceEnabled) return; - if (ctx.fastCommandAvailable === undefined) { + if (ctx.extensionCommandNames === undefined) { const commands = yield* request(ctx, { type: "get_commands" }); - ctx.fastCommandAvailable = piRpcAdvertisesCommand(commands, PI_CODEX_FAST_COMMAND); + ctx.extensionCommandNames = piRpcCommandNames(commands); } - if (!ctx.fastCommandAvailable) { + return ctx.extensionCommandNames.has(commandName); + }); + + const syncContextWindow = ( + ctx: PiSessionContext, + model: string | undefined, + selection: string | undefined, + ) => + Effect.gen(function* () { + if (selection === undefined) return; + const selectionKey = `${model ?? ""}\u0000${selection}`; + if (selectionKey === ctx.contextWindowSelectionKey) return; + if (!(yield* piAdvertisesCommand(ctx, PI_CONTEXT_COMMAND))) { + if (selection === PI_AUTO_CONTEXT_WINDOW) { + ctx.contextWindowSelectionKey = selectionKey; + return; + } + return yield* new ProviderAdapterRequestError({ + provider: PROVIDER, + method: PI_CONTEXT_COMMAND, + detail: + "Pi does not advertise the /context command required for context-window selection. Enable the effort-commands extension in the selected Pi profile.", + }); + } + yield* request(ctx, { + type: "prompt", + message: `/${PI_CONTEXT_COMMAND} ${selection}`, + }); + ctx.contextWindowSelectionKey = selectionKey; + }); + + const syncFastService = (ctx: PiSessionContext, enabled: boolean | undefined) => + Effect.gen(function* () { + if (enabled === undefined || enabled === ctx.fastServiceEnabled) return; + if (!(yield* piAdvertisesCommand(ctx, PI_CODEX_FAST_COMMAND))) { if (!enabled) { ctx.fastServiceEnabled = false; return; @@ -575,13 +615,22 @@ export function makePiAdapter(piSettings: PiSettings, options?: PiAdapterLiveOpt const thinkingLevel = parsePiThinkingLevel( getModelSelectionStringOptionValue(selection, PI_THINKING_OPTION_ID), ); + const contextWindow = parsePiContextWindow( + getModelSelectionStringOptionValue(selection, PI_CONTEXT_WINDOW_OPTION_ID), + ); const fastServiceEnabled = supportsPiCodexFastService(model) ? (parsePiFastServiceEnabled( getModelSelectionStringOptionValue(selection, PI_SERVICE_TIER_OPTION_ID), ) ?? false) : undefined; const profile = getModelSelectionStringOptionValue(selection, PI_PROFILE_OPTION_ID)?.trim(); - return { model, thinkingLevel, fastServiceEnabled, profile: profile || undefined }; + return { + model, + thinkingLevel, + contextWindow, + fastServiceEnabled, + profile: profile || undefined, + }; }; const startSession: PiAdapterShape["startSession"] = (input) => @@ -606,9 +655,8 @@ export function makePiAdapter(piSettings: PiSettings, options?: PiAdapterLiveOpt yield* stopSessionInternal(existing); } - const { model, thinkingLevel, fastServiceEnabled, profile } = resolveModelSelection( - input.modelSelection, - ); + const { model, thinkingLevel, contextWindow, fastServiceEnabled, profile } = + resolveModelSelection(input.modelSelection); const resumeSessionId = isRecord(input.resumeCursor) && typeof input.resumeCursor.piSessionId === "string" ? input.resumeCursor.piSessionId @@ -632,7 +680,8 @@ export function makePiAdapter(piSettings: PiSettings, options?: PiAdapterLiveOpt assistantItemId: undefined, assistantText: "", reasoningText: "", - fastCommandAvailable: undefined, + extensionCommandNames: undefined, + contextWindowSelectionKey: undefined, fastServiceEnabled: undefined, sendSemaphore, stopped: false, @@ -698,6 +747,7 @@ export function makePiAdapter(piSettings: PiSettings, options?: PiAdapterLiveOpt updatedAt: now, }; (ctx as { session: ProviderSession }).session = session; + yield* syncContextWindow(ctx, model, contextWindow); yield* syncFastService(ctx, fastServiceEnabled); sessions.set(input.threadId, ctx); @@ -732,9 +782,8 @@ export function makePiAdapter(piSettings: PiSettings, options?: PiAdapterLiveOpt Effect.flatMap(requireSession(input.threadId), (ctx) => ctx.sendSemaphore.withPermit( Effect.gen(function* () { - const { model, thinkingLevel, fastServiceEnabled } = resolveModelSelection( - input.modelSelection, - ); + const { model, thinkingLevel, contextWindow, fastServiceEnabled } = + resolveModelSelection(input.modelSelection); // In-session model / thinking switch. if (model && model !== ctx.session.model) { @@ -744,6 +793,7 @@ export function makePiAdapter(piSettings: PiSettings, options?: PiAdapterLiveOpt if (thinkingLevel) { yield* request(ctx, { type: "set_thinking_level", level: thinkingLevel }); } + yield* syncContextWindow(ctx, model, contextWindow); yield* syncFastService(ctx, fastServiceEnabled); const text = input.input?.trim(); @@ -899,8 +949,7 @@ export function makePiAdapter(piSettings: PiSettings, options?: PiAdapterLiveOpt // model by Pi. Verify the private bridge command exists before sending // it so opening a thread without the optional pi-subagents extension // can never create an unintended user turn. - const commands = yield* request(ctx, { type: "get_commands" }); - if (!piRpcAdvertisesCommand(commands, "subagents-rpc")) { + if (!(yield* piAdvertisesCommand(ctx, "subagents-rpc"))) { return yield* new ProviderAdapterValidationError({ provider: PROVIDER, operation: "controlSubagent", diff --git a/apps/server/src/provider/pi/piModelDiscovery.test.ts b/apps/server/src/provider/pi/piModelDiscovery.test.ts index 29f51073418..436f8cd06ec 100644 --- a/apps/server/src/provider/pi/piModelDiscovery.test.ts +++ b/apps/server/src/provider/pi/piModelDiscovery.test.ts @@ -46,6 +46,43 @@ describe("piModelCapabilities", () => { expect(optionIds).toEqual(["off", "minimal", "low", "medium", "high"]); }); + it("advertises context-window choices from the configured default through the catalog max", () => { + const capabilities = piModelCapabilities( + { + id: "gpt-5.6-sol", + provider: "openai-codex", + contextWindow: 272_000, + }, + { contextCommandAvailable: true }, + ); + const descriptor = capabilities.optionDescriptors?.find( + (option) => option.id === "contextWindow", + ); + expect(descriptor).toMatchObject({ + id: "contextWindow", + label: "Context Window", + type: "select", + options: [ + { id: "auto", label: "Auto (272K)", isDefault: true }, + { id: "128k", label: "128K" }, + { id: "200k", label: "200K" }, + { id: "256k", label: "256K" }, + { id: "272k", label: "272K" }, + { id: "372k", label: "372K" }, + ], + }); + }); + + it("does not advertise context-window controls without the /context extension command", () => { + const capabilities = piModelCapabilities( + { id: "gpt-x", provider: "custom", contextWindow: 200_000 }, + { contextCommandAvailable: false }, + ); + expect(capabilities.optionDescriptors?.some((option) => option.id === "contextWindow")).toBe( + false, + ); + }); + it("advertises Standard and Fast service tiers for supported OpenAI Codex models", () => { const capabilities = piModelCapabilities( { @@ -82,7 +119,7 @@ describe("piModelCapabilities", () => { it("does not advertise Fast service for unsupported Codex model ids", () => { const capabilities = piModelCapabilities( { - id: "gpt-5.6-sol", + id: "gpt-5.4-mini", provider: "openai-codex", reasoning: true, }, @@ -123,6 +160,34 @@ describe("discoverPiModelsWithSdk", () => { ); }); + it("exposes context controls when the loaded profile registers the command", async () => { + const result = await discoverPiModelsWithSdk({ + createAgentSessionServices: async () => ({ + modelRegistry: { + getAvailable: () => [ + { + id: "gpt-5.6-sol", + name: "GPT-5.6 Sol", + provider: "openai-codex", + contextWindow: 272_000, + }, + ], + getError: () => undefined, + }, + resourceLoader: { + getExtensions: () => ({ + extensions: [{ commands: new Map([["context", {}]]) }], + }), + }, + diagnostics: [], + }), + }); + + expect(result.models[0]?.capabilities?.optionDescriptors).toContainEqual( + expect.objectContaining({ id: "contextWindow", label: "Context Window" }), + ); + }); + it("loads extension-registered providers before enumerating available models", async () => { let receivedOptions: Record | undefined; const result = await discoverPiModelsWithSdk( diff --git a/apps/server/src/provider/pi/piModelDiscovery.ts b/apps/server/src/provider/pi/piModelDiscovery.ts index fffdf838880..d1a5b26637c 100644 --- a/apps/server/src/provider/pi/piModelDiscovery.ts +++ b/apps/server/src/provider/pi/piModelDiscovery.ts @@ -24,7 +24,10 @@ import * as Effect from "effect/Effect"; import { buildSelectOptionDescriptor } from "../providerSnapshot.ts"; import { + PI_AUTO_CONTEXT_WINDOW, PI_CODEX_FAST_COMMAND, + PI_CONTEXT_COMMAND, + PI_CONTEXT_WINDOW_OPTION_ID, PI_FAST_SERVICE_TIER, PI_SERVICE_TIER_OPTION_ID, PI_STANDARD_SERVICE_TIER, @@ -42,6 +45,7 @@ interface PiSdkModel { readonly provider: string; readonly reasoning?: boolean; readonly thinkingLevelMap?: Record | undefined; + readonly contextWindow?: number | undefined; } export interface PiModelDiscoveryResult { @@ -106,6 +110,67 @@ function piModelSlug(model: PiSdkModel): string { */ export interface PiModelCapabilityOptions { readonly codexFastCommandAvailable?: boolean | undefined; + readonly contextCommandAvailable?: boolean | undefined; +} + +const PI_CONTEXT_WINDOW_PRESETS = [ + 128_000, 200_000, 256_000, 272_000, 372_000, 400_000, 1_000_000, 1_050_000, +] as const; +const PI_CATALOG_CONTEXT_MAX_WINDOWS = new Map([ + ["openai-codex/gpt-5.6-luna", 372_000], + ["openai-codex/gpt-5.6-sol", 372_000], + ["openai-codex/gpt-5.6-terra", 372_000], +]); + +function formatContextWindowValue(tokens: number): string { + if (tokens >= 1_000_000 && tokens % 1_000_000 === 0) return `${tokens / 1_000_000}m`; + if (tokens >= 1_000 && tokens % 1_000 === 0) return `${tokens / 1_000}k`; + return String(tokens); +} + +function formatContextWindowLabel(tokens: number): string { + if (tokens >= 1_000_000) { + return `${Number((tokens / 1_000_000).toFixed(2))}M`; + } + if (tokens >= 1_000) { + return `${Number((tokens / 1_000).toFixed(1))}K`; + } + return String(tokens); +} + +function piContextWindowChoices(model: PiSdkModel) { + const configuredWindow = model.contextWindow; + if ( + typeof configuredWindow !== "number" || + !Number.isFinite(configuredWindow) || + configuredWindow < 1_000 + ) { + return []; + } + const defaultWindow = Math.floor(configuredWindow); + const maximumWindow = Math.max( + defaultWindow, + PI_CATALOG_CONTEXT_MAX_WINDOWS.get(piModelSlug(model)) ?? defaultWindow, + ); + const manualWindows = Array.from( + new Set([ + ...PI_CONTEXT_WINDOW_PRESETS.filter((tokens) => tokens <= maximumWindow), + defaultWindow, + maximumWindow, + ]), + ).sort((left, right) => left - right); + + return [ + { + value: PI_AUTO_CONTEXT_WINDOW, + label: `Auto (${formatContextWindowLabel(defaultWindow)})`, + isDefault: true, + }, + ...manualWindows.map((tokens) => ({ + value: formatContextWindowValue(tokens), + label: formatContextWindowLabel(tokens), + })), + ]; } export function piModelCapabilities( @@ -125,13 +190,28 @@ export function piModelCapabilities( optionDescriptors.push( buildSelectOptionDescriptor({ id: PI_THINKING_OPTION_ID, - label: "Thinking", + label: "Reasoning", options: levels.map((level) => ({ value: level, label: level })), }), ); } } + if (options.contextCommandAvailable === true) { + const contextWindowOptions = piContextWindowChoices(model); + if (contextWindowOptions.length > 0) { + optionDescriptors.push( + buildSelectOptionDescriptor({ + id: PI_CONTEXT_WINDOW_OPTION_ID, + label: "Context Window", + description: + "Auto uses Pi's configured model limit. Manual values can lower it or select a separately known catalog maximum.", + options: contextWindowOptions, + }), + ); + } + } + if ( options.codexFastCommandAvailable === true && supportsPiCodexFastService(piModelSlug(model)) @@ -191,12 +271,15 @@ export async function discoverPiModelsWithSdk( }, }); const available = services.modelRegistry.getAvailable(); - const codexFastCommandAvailable = - services.resourceLoader - ?.getExtensions() - .extensions.some((extension) => extension.commands.has(PI_CODEX_FAST_COMMAND)) ?? false; + const extensions = services.resourceLoader?.getExtensions().extensions ?? []; + const codexFastCommandAvailable = extensions.some((extension) => + extension.commands.has(PI_CODEX_FAST_COMMAND), + ); + const contextCommandAvailable = extensions.some((extension) => + extension.commands.has(PI_CONTEXT_COMMAND), + ); const models = available.map((model) => - toServerProviderModel(model, { codexFastCommandAvailable }), + toServerProviderModel(model, { codexFastCommandAvailable, contextCommandAvailable }), ); const errors = [ services.modelRegistry.getError(), diff --git a/apps/server/src/provider/pi/piRpcProtocol.test.ts b/apps/server/src/provider/pi/piRpcProtocol.test.ts index d23e52b482c..58ce52b23c5 100644 --- a/apps/server/src/provider/pi/piRpcProtocol.test.ts +++ b/apps/server/src/provider/pi/piRpcProtocol.test.ts @@ -7,6 +7,7 @@ import { buildPiRpcArgs, buildPiRpcEnv, extractPiAssistantText, + parsePiContextWindow, parsePiFastServiceEnabled, parsePiSubagentNotification, parsePiThinkingLevel, @@ -152,13 +153,28 @@ describe("Pi Codex Fast service", () => { it("recognizes supported models and service-tier values", () => { expect(supportsPiCodexFastService("openai-codex/gpt-5.5")).toBe(true); expect(supportsPiCodexFastService("openai-codex/gpt-5.4")).toBe(true); - expect(supportsPiCodexFastService("openai-codex/gpt-5.6-sol")).toBe(false); + expect(supportsPiCodexFastService("openai-codex/gpt-5.6-sol")).toBe(true); + expect(supportsPiCodexFastService("openai-codex/gpt-5.6-terra")).toBe(true); + expect(supportsPiCodexFastService("openai-codex/gpt-5.6-luna")).toBe(true); + expect(supportsPiCodexFastService("openai-codex/gpt-5.4-mini")).toBe(false); expect(parsePiFastServiceEnabled("priority")).toBe(true); expect(parsePiFastServiceEnabled("default")).toBe(false); expect(parsePiFastServiceEnabled("flex")).toBeUndefined(); }); }); +describe("parsePiContextWindow", () => { + it("accepts safe context values and rejects command injection or invalid values", () => { + expect(parsePiContextWindow("auto")).toBe("auto"); + expect(parsePiContextWindow(" 372K ")).toBe("372k"); + expect(parsePiContextWindow("1.05m")).toBe("1.05m"); + expect(parsePiContextWindow("/context 200k")).toBeUndefined(); + expect(parsePiContextWindow("200k\n/fast on")).toBeUndefined(); + expect(parsePiContextWindow("0")).toBeUndefined(); + expect(parsePiContextWindow(undefined)).toBeUndefined(); + }); +}); + describe("autoRespondToExtensionUi (yolo mode)", () => { const base = { type: "extension_ui_request" as const, id: "req-1" }; diff --git a/apps/server/src/provider/pi/piRpcProtocol.ts b/apps/server/src/provider/pi/piRpcProtocol.ts index 7b7d9d96603..9e50621dfcc 100644 --- a/apps/server/src/provider/pi/piRpcProtocol.ts +++ b/apps/server/src/provider/pi/piRpcProtocol.ts @@ -52,12 +52,21 @@ export type PiThinkingLevel = (typeof PI_THINKING_LEVELS)[number]; */ export const PI_THINKING_OPTION_ID = "reasoning"; -/** Pi model option + extension command used for OpenAI Codex priority service. */ +/** Pi model options + extension commands exposed in the composer. */ +export const PI_CONTEXT_WINDOW_OPTION_ID = "contextWindow"; +export const PI_CONTEXT_COMMAND = "context"; +export const PI_AUTO_CONTEXT_WINDOW = "auto"; export const PI_SERVICE_TIER_OPTION_ID = "serviceTier"; export const PI_STANDARD_SERVICE_TIER = "default"; export const PI_FAST_SERVICE_TIER = "priority"; export const PI_CODEX_FAST_COMMAND = "fast"; -const PI_CODEX_FAST_MODEL_SLUGS = new Set(["openai-codex/gpt-5.4", "openai-codex/gpt-5.5"]); +const PI_CODEX_FAST_MODEL_SLUGS = new Set([ + "openai-codex/gpt-5.4", + "openai-codex/gpt-5.5", + "openai-codex/gpt-5.6-luna", + "openai-codex/gpt-5.6-sol", + "openai-codex/gpt-5.6-terra", +]); export function supportsPiCodexFastService(modelSlug: string | undefined): boolean { return modelSlug !== undefined && PI_CODEX_FAST_MODEL_SLUGS.has(modelSlug); @@ -69,6 +78,19 @@ export function parsePiFastServiceEnabled(value: unknown): boolean | undefined { return undefined; } +/** Validate a composer value before interpolating it into Pi's `/context` command. */ +export function parsePiContextWindow(value: unknown): string | undefined { + if (typeof value !== "string") return undefined; + const normalized = value.trim().toLowerCase(); + if (normalized === PI_AUTO_CONTEXT_WINDOW) return normalized; + if (!/^[0-9]+(?:\.[0-9]+)?(?:k|m)?$/.test(normalized)) return undefined; + + const suffix = normalized.at(-1); + const numericText = suffix === "k" || suffix === "m" ? normalized.slice(0, -1) : normalized; + const numericValue = Number(numericText); + return Number.isFinite(numericValue) && numericValue > 0 ? normalized : undefined; +} + export function parsePiThinkingLevel(value: unknown): PiThinkingLevel | undefined { return typeof value === "string" && (PI_THINKING_LEVELS as ReadonlyArray).includes(value) ? (value as PiThinkingLevel) diff --git a/apps/web/src/components/chat/composerProviderState.test.tsx b/apps/web/src/components/chat/composerProviderState.test.tsx index 06a9eac8ec6..bf2074f89a2 100644 --- a/apps/web/src/components/chat/composerProviderState.test.tsx +++ b/apps/web/src/components/chat/composerProviderState.test.tsx @@ -167,6 +167,44 @@ describe("getComposerProviderState", () => { ); }); + it("dispatches Pi reasoning, context, and Fast controls from the composer settings chip", () => { + const state = getComposerProviderState({ + provider: ProviderDriverKind.make("pi"), + model: MODEL, + models: modelWith([ + selectDescriptor("reasoning", [ + { id: "high", label: "High" }, + { id: "xhigh", label: "Extra High", isDefault: true }, + ]), + selectDescriptor("contextWindow", [ + { id: "auto", label: "Auto (272K)", isDefault: true }, + { id: "372k", label: "372K" }, + ]), + selectDescriptor("serviceTier", [ + { id: "default", label: "Standard", isDefault: true }, + { id: "priority", label: "Fast" }, + ]), + selectDescriptor(PI_PROFILE_OPTION_ID, [{ id: "coder", label: "coder", isDefault: true }]), + ]), + modelOptions: selections( + ["reasoning", "high"], + ["contextWindow", "372k"], + ["serviceTier", "priority"], + ), + }); + + expect(state).toEqual({ + provider: ProviderDriverKind.make("pi"), + promptEffort: "high", + modelOptionsForDispatch: selections( + ["reasoning", "high"], + ["contextWindow", "372k"], + ["serviceTier", "priority"], + [PI_PROFILE_OPTION_ID, "coder"], + ), + }); + }); + it("dispatches a Pi profile without treating it as prompt effort", () => { const state = getComposerProviderState({ provider: ProviderDriverKind.make("pi"), diff --git a/docs/providers/pi.md b/docs/providers/pi.md index 308dc63c9b0..decef2bc6cd 100644 --- a/docs/providers/pi.md +++ b/docs/providers/pi.md @@ -18,7 +18,9 @@ T3 does not copy or replace Pi configuration. Each thread starts Pi in that thre ## Runtime behavior -T3 keeps one long-lived `pi --mode rpc` process per active thread. It starts Pi with `--approve`, uses LF-delimited JSON RPC framing, and persists Pi's authoritative session id so a thread can resume after the server restarts. Model and thinking-level choices come from Pi's own model registry and authentication state. T3 loads Pi extensions before discovery, so extension-registered providers such as `claude-agent-sdk` appear alongside built-in providers. Only models with configured credentials are shown, and each picker row identifies the underlying Pi model provider. Credentials are not returned to the client. +T3 keeps one long-lived `pi --mode rpc` process per active thread. It starts Pi with `--approve`, uses LF-delimited JSON RPC framing, and persists Pi's authoritative session id so a thread can resume after the server restarts. Model and reasoning-level choices come from Pi's own model registry and authentication state. T3 loads Pi extensions before discovery, so extension-registered providers such as `claude-agent-sdk` appear alongside built-in providers. Only models with configured credentials are shown, and each picker row identifies the underlying Pi model provider. Credentials are not returned to the client. + +The composer settings control beside the model picker exposes the selected model's reasoning levels. When the active profile provides the compatible `effort-commands` extension, the same control also exposes context-window choices and Codex Fast service for supported OpenAI Codex models. T3 applies model, reasoning, context, and service-tier changes in order before sending the user prompt. Profile selection remains available there for new drafts. Extension confirmation prompts are approved automatically for the full-access runtime. Prompts that require fabricated text input are cancelled instead. From dcca8bf21c27a18ad39fcf062a1a0ae9ffe177d1 Mon Sep 17 00:00:00 2001 From: YJosh Date: Tue, 14 Jul 2026 14:44:57 +0200 Subject: [PATCH 2/4] Default Codex reasoning effort to high - Fall back to high when catalog defaults are unavailable - Apply the same default to discovered Pi Codex models --- .../src/provider/Layers/CodexProvider.test.ts | 32 +++++++++++++++++++ .../src/provider/Layers/CodexProvider.ts | 13 ++++++-- .../src/provider/pi/piModelDiscovery.test.ts | 16 ++++++++++ .../src/provider/pi/piModelDiscovery.ts | 8 ++++- 4 files changed, 65 insertions(+), 4 deletions(-) diff --git a/apps/server/src/provider/Layers/CodexProvider.test.ts b/apps/server/src/provider/Layers/CodexProvider.test.ts index 0e21b76306b..953cb0c691c 100644 --- a/apps/server/src/provider/Layers/CodexProvider.test.ts +++ b/apps/server/src/provider/Layers/CodexProvider.test.ts @@ -64,6 +64,38 @@ it("maps current Codex model capability fields", () => { ]); }); +it("falls back to high when the catalog reasoning default is unavailable", () => { + const capabilities = mapCodexModelCapabilities({ + additionalSpeedTiers: [], + defaultReasoningEffort: "unavailable", + defaultServiceTier: null, + description: "Test model", + displayName: "GPT Test", + hidden: false, + id: "gpt-test", + isDefault: true, + model: "gpt-test", + serviceTiers: [], + supportedReasoningEfforts: [ + { description: "Low reasoning", reasoningEffort: "low" }, + { description: "High reasoning", reasoningEffort: "high" }, + ], + }); + + assert.deepStrictEqual(capabilities.optionDescriptors, [ + { + id: "reasoningEffort", + label: "Reasoning", + type: "select", + options: [ + { id: "low", label: "Low" }, + { id: "high", label: "High", isDefault: true }, + ], + currentValue: "high", + }, + ]); +}); + it("uses standard routing when the catalog has no default service tier", () => { const capabilities = mapCodexModelCapabilities({ additionalSpeedTiers: ["fast"], diff --git a/apps/server/src/provider/Layers/CodexProvider.ts b/apps/server/src/provider/Layers/CodexProvider.ts index a2182cfb73c..e2d532a8b2f 100644 --- a/apps/server/src/provider/Layers/CodexProvider.ts +++ b/apps/server/src/provider/Layers/CodexProvider.ts @@ -109,8 +109,16 @@ function codexAccountEmail(account: CodexSchema.V2GetAccountResponse["account"]) export function mapCodexModelCapabilities( model: CodexSchema.V2ModelListResponse__Model, ): ModelCapabilities { - const reasoningOptions = model.supportedReasoningEfforts.map(({ reasoningEffort }) => - reasoningEffort === model.defaultReasoningEffort + const supportedReasoningEfforts = model.supportedReasoningEfforts.map( + ({ reasoningEffort }) => reasoningEffort, + ); + const defaultReasoning = supportedReasoningEfforts.includes(model.defaultReasoningEffort) + ? model.defaultReasoningEffort + : supportedReasoningEfforts.includes("high") + ? "high" + : undefined; + const reasoningOptions = supportedReasoningEfforts.map((reasoningEffort) => + reasoningEffort === defaultReasoning ? { id: reasoningEffort, label: reasoningEffortLabel(reasoningEffort), @@ -121,7 +129,6 @@ export function mapCodexModelCapabilities( label: reasoningEffortLabel(reasoningEffort), }, ); - const defaultReasoning = reasoningOptions.find((option) => option.isDefault)?.id; const serviceTiers = model.serviceTiers && model.serviceTiers.length > 0 ? model.serviceTiers diff --git a/apps/server/src/provider/pi/piModelDiscovery.test.ts b/apps/server/src/provider/pi/piModelDiscovery.test.ts index 436f8cd06ec..b8d74d3be98 100644 --- a/apps/server/src/provider/pi/piModelDiscovery.test.ts +++ b/apps/server/src/provider/pi/piModelDiscovery.test.ts @@ -46,6 +46,22 @@ describe("piModelCapabilities", () => { expect(optionIds).toEqual(["off", "minimal", "low", "medium", "high"]); }); + it("defaults OpenAI Codex reasoning to high when no selection exists", () => { + const capabilities = piModelCapabilities({ + id: "gpt-5.6-sol", + provider: "openai-codex", + reasoning: true, + thinkingLevelMap: { off: null, minimal: null, max: "max" }, + }); + const descriptor = capabilities.optionDescriptors?.find((option) => option.id === "reasoning"); + + expect(descriptor).toMatchObject({ + type: "select", + currentValue: "high", + options: expect.arrayContaining([{ id: "high", label: "high", isDefault: true }]), + }); + }); + it("advertises context-window choices from the configured default through the catalog max", () => { const capabilities = piModelCapabilities( { diff --git a/apps/server/src/provider/pi/piModelDiscovery.ts b/apps/server/src/provider/pi/piModelDiscovery.ts index d1a5b26637c..3cada4fe57d 100644 --- a/apps/server/src/provider/pi/piModelDiscovery.ts +++ b/apps/server/src/provider/pi/piModelDiscovery.ts @@ -187,11 +187,17 @@ export function piModelCapabilities( return true; }); if (levels.length > 0) { + const defaultLevel = + model.provider === "openai-codex" && levels.includes("high") ? "high" : undefined; optionDescriptors.push( buildSelectOptionDescriptor({ id: PI_THINKING_OPTION_ID, label: "Reasoning", - options: levels.map((level) => ({ value: level, label: level })), + options: levels.map((level) => ({ + value: level, + label: level, + ...(level === defaultLevel ? { isDefault: true } : {}), + })), }), ); } From eca9847140909254d0dff9e1023e7dcf371993ec Mon Sep 17 00:00:00 2001 From: YJosh Date: Tue, 14 Jul 2026 14:57:51 +0200 Subject: [PATCH 3/4] Default Pi reasoning to high across providers - Apply the high reasoning default to every supported Pi provider - Expand coverage for Codex, Claude SDK, and OpenCode models - Remove the obsolete root README --- README.md | 91 ------------------- .../src/provider/pi/piModelDiscovery.test.ts | 30 +++--- .../src/provider/pi/piModelDiscovery.ts | 3 +- 3 files changed, 18 insertions(+), 106 deletions(-) delete mode 100644 README.md diff --git a/README.md b/README.md deleted file mode 100644 index d99cb2a94a7..00000000000 --- a/README.md +++ /dev/null @@ -1,91 +0,0 @@ -# T3 Code - -T3 Code is a minimal web GUI for coding agents (currently Codex, Claude, Cursor, OpenCode, and Pi, with more coming soon). - -## Installation - -> [!WARNING] -> T3 Code currently supports Codex, Claude, Cursor, OpenCode, and Pi. -> Install and authenticate at least one provider before use: -> -> - Codex: install [Codex CLI](https://developers.openai.com/codex/cli) and run `codex login` -> - Claude: install [Claude Code](https://claude.com/product/claude-code) and run `claude auth login` -> - Cursor: install [Cursor CLI](https://cursor.com/cli) and run `cursor-agent login` -> - OpenCode: install [OpenCode](https://opencode.ai) and run `opencode auth login` -> - Pi: install the [Pi coding agent](https://github.com/badlogic/pi-mono/tree/main/packages/coding-agent) and configure its model-provider authentication - -### Run without installing - -```bash -npx t3@latest -``` - -Tip: Use `npx t3@latest --help` for the full CLI reference. - -### Desktop app - -Install the latest version of the desktop app from [GitHub Releases](https://github.com/pingdotgg/t3code/releases), or from your favorite package registry: - -#### Windows (`winget`) - -```bash -winget install T3Tools.T3Code -``` - -#### macOS (Homebrew) - -```bash -brew install --cask t3-code -``` - -#### Arch Linux (AUR) - -```bash -yay -S t3code-bin -``` - -## Some notes - -We are very very early in this project. Expect bugs. - -We are not accepting contributions yet. - -There's no public docs site yet, checkout the miscellaneous markdown files in [docs](./docs). - -## Documentation - -- [Getting started](./docs/getting-started/quick-start.md) -- [Architecture overview](./docs/architecture/overview.md) -- Provider guides: [Codex](./docs/providers/codex.md), [Claude](./docs/providers/claude.md), [Pi](./docs/providers/pi.md) -- [Operations](./docs/operations/ci.md) -- [Reference](./docs/reference/encyclopedia.md) - -## If you REALLY want to contribute still.... read this first - -### Install `vp` - -T3 Code uses Vite+ so you'll need to install the global `vp` command-line tool. - -#### macOS / Linux - -```bash -curl -fsSL https://vite.plus | bash -``` - -#### Windows - -```bash -irm https://vite.plus/ps1 | iex -``` - -Checkout their getting started guide for more information: https://viteplus.dev/guide/ - -### Install dependencies - -```bash -vp i -``` - -Read [CONTRIBUTING.md](./CONTRIBUTING.md) before opening an issue or PR. - -Need support? Join the [Discord](https://discord.gg/jn4EGJjrvv). diff --git a/apps/server/src/provider/pi/piModelDiscovery.test.ts b/apps/server/src/provider/pi/piModelDiscovery.test.ts index b8d74d3be98..ff41db556e7 100644 --- a/apps/server/src/provider/pi/piModelDiscovery.test.ts +++ b/apps/server/src/provider/pi/piModelDiscovery.test.ts @@ -46,20 +46,24 @@ describe("piModelCapabilities", () => { expect(optionIds).toEqual(["off", "minimal", "low", "medium", "high"]); }); - it("defaults OpenAI Codex reasoning to high when no selection exists", () => { - const capabilities = piModelCapabilities({ - id: "gpt-5.6-sol", - provider: "openai-codex", - reasoning: true, - thinkingLevelMap: { off: null, minimal: null, max: "max" }, - }); - const descriptor = capabilities.optionDescriptors?.find((option) => option.id === "reasoning"); + it("defaults reasoning to high for every Pi model provider when no selection exists", () => { + for (const provider of ["openai-codex", "claude-agent-sdk", "opencode-go"]) { + const capabilities = piModelCapabilities({ + id: "reasoning-model", + provider, + reasoning: true, + thinkingLevelMap: { off: null, minimal: null, max: "max" }, + }); + const descriptor = capabilities.optionDescriptors?.find( + (option) => option.id === "reasoning", + ); - expect(descriptor).toMatchObject({ - type: "select", - currentValue: "high", - options: expect.arrayContaining([{ id: "high", label: "high", isDefault: true }]), - }); + expect(descriptor).toMatchObject({ + type: "select", + currentValue: "high", + options: expect.arrayContaining([{ id: "high", label: "high", isDefault: true }]), + }); + } }); it("advertises context-window choices from the configured default through the catalog max", () => { diff --git a/apps/server/src/provider/pi/piModelDiscovery.ts b/apps/server/src/provider/pi/piModelDiscovery.ts index 3cada4fe57d..64627ea270c 100644 --- a/apps/server/src/provider/pi/piModelDiscovery.ts +++ b/apps/server/src/provider/pi/piModelDiscovery.ts @@ -187,8 +187,7 @@ export function piModelCapabilities( return true; }); if (levels.length > 0) { - const defaultLevel = - model.provider === "openai-codex" && levels.includes("high") ? "high" : undefined; + const defaultLevel = levels.includes("high") ? "high" : undefined; optionDescriptors.push( buildSelectOptionDescriptor({ id: PI_THINKING_OPTION_ID, From 86fac025f188d19d2fd7b364373df7b5fa4c9664 Mon Sep 17 00:00:00 2001 From: YJosh Date: Tue, 14 Jul 2026 15:33:04 +0200 Subject: [PATCH 4/4] Revalidate Pi profile runtime options --- .../src/provider/Layers/PiAdapter.test.ts | 59 +++++++++++++++++++ apps/server/src/provider/Layers/PiAdapter.ts | 42 +++++++------ docs/providers/pi.md | 2 +- 3 files changed, 84 insertions(+), 19 deletions(-) diff --git a/apps/server/src/provider/Layers/PiAdapter.test.ts b/apps/server/src/provider/Layers/PiAdapter.test.ts index 2d67c99c9eb..b0b521a1a38 100644 --- a/apps/server/src/provider/Layers/PiAdapter.test.ts +++ b/apps/server/src/provider/Layers/PiAdapter.test.ts @@ -330,6 +330,65 @@ describe("makePiAdapter", () => { }).pipe(Effect.scoped, Effect.provide(TestEnv)), ); + it.effect("drops stale profile options when the live Pi profile lacks their commands", () => + Effect.gen(function* () { + const fake = yield* makeFakePi(); + const adapter = yield* makePiAdapter(settings, { instanceId: INSTANCE }).pipe( + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, fake.spawner), + ); + const events = yield* Queue.unbounded(); + yield* Stream.runForEach(adapter.streamEvents, (event) => Queue.offer(events, event)).pipe( + Effect.forkScoped, + ); + const threadId = ThreadId.make("77777777-7777-4777-8777-777777777777"); + const staleSelection: ModelSelection = { + instanceId: INSTANCE, + model: "openai-codex/gpt-5.6-sol", + options: [ + { id: "reasoning", value: "high" }, + { id: "contextWindow", value: "372k" }, + { id: "serviceTier", value: "priority" }, + { id: "profile", value: "without-effort-commands" }, + ], + }; + + yield* adapter.startSession({ + threadId, + cwd: process.cwd(), + runtimeMode: "full-access", + modelSelection: staleSelection, + }); + + expect(fake.captured.args).toContain("without-effort-commands"); + const contextWarning = yield* takeEventOfType(events, "runtime.warning"); + const fastWarning = yield* takeEventOfType(events, "runtime.warning"); + expect( + contextWarning.type === "runtime.warning" ? contextWarning.payload.message : "", + ).toContain("does not provide /context"); + expect(fastWarning.type === "runtime.warning" ? fastWarning.payload.message : "").toContain( + "does not provide /fast", + ); + expect(fake.written).not.toContainEqual( + expect.objectContaining({ type: "prompt", message: "/context 372k" }), + ); + expect(fake.written).not.toContainEqual( + expect.objectContaining({ type: "prompt", message: "/fast on" }), + ); + + yield* adapter.sendTurn({ + threadId, + input: "Continue without profile-specific options", + modelSelection: staleSelection, + }); + const prompt = yield* fake.takeStdinUntil( + (command) => + command.type === "prompt" && + command.message === "Continue without profile-specific options", + ); + expect(prompt.message).toBe("Continue without profile-specific options"); + }).pipe(Effect.scoped, Effect.provide(TestEnv)), + ); + it.effect("keeps opposite Fast tiers atomic with concurrent sends", () => Effect.gen(function* () { const fake = yield* makeFakePi({ fastCommand: true }); diff --git a/apps/server/src/provider/Layers/PiAdapter.ts b/apps/server/src/provider/Layers/PiAdapter.ts index 2e44b417041..afb89532ede 100644 --- a/apps/server/src/provider/Layers/PiAdapter.ts +++ b/apps/server/src/provider/Layers/PiAdapter.ts @@ -277,16 +277,19 @@ export function makePiAdapter(piSettings: PiSettings, options?: PiAdapterLiveOpt const selectionKey = `${model ?? ""}\u0000${selection}`; if (selectionKey === ctx.contextWindowSelectionKey) return; if (!(yield* piAdvertisesCommand(ctx, PI_CONTEXT_COMMAND))) { - if (selection === PI_AUTO_CONTEXT_WINDOW) { - ctx.contextWindowSelectionKey = selectionKey; - return; + // Capabilities are discovered with the configured default profile, + // while a draft can select another profile. Revalidate against the + // live session and drop a stale option instead of failing the thread. + ctx.contextWindowSelectionKey = selectionKey; + if (selection !== PI_AUTO_CONTEXT_WINDOW) { + yield* emitWarning( + ctx.threadId, + ctx.activeTurnId, + "Ignoring the context-window selection because this Pi profile does not provide /context.", + { model, selection }, + ); } - return yield* new ProviderAdapterRequestError({ - provider: PROVIDER, - method: PI_CONTEXT_COMMAND, - detail: - "Pi does not advertise the /context command required for context-window selection. Enable the effort-commands extension in the selected Pi profile.", - }); + return; } yield* request(ctx, { type: "prompt", @@ -299,16 +302,19 @@ export function makePiAdapter(piSettings: PiSettings, options?: PiAdapterLiveOpt Effect.gen(function* () { if (enabled === undefined || enabled === ctx.fastServiceEnabled) return; if (!(yield* piAdvertisesCommand(ctx, PI_CODEX_FAST_COMMAND))) { - if (!enabled) { - ctx.fastServiceEnabled = false; - return; + // As with /context, a per-draft profile can differ from the profile + // used for provider discovery. Treat an unavailable command as an + // unsupported option for this session rather than a startup error. + ctx.fastServiceEnabled = enabled; + if (enabled) { + yield* emitWarning( + ctx.threadId, + ctx.activeTurnId, + "Ignoring Codex Fast because this Pi profile does not provide /fast.", + { model: ctx.session.model }, + ); } - return yield* new ProviderAdapterRequestError({ - provider: PROVIDER, - method: PI_CODEX_FAST_COMMAND, - detail: - "Pi does not advertise the /fast command required for Codex priority service. Enable the effort-commands extension in the selected Pi profile.", - }); + return; } yield* request(ctx, { type: "prompt", diff --git a/docs/providers/pi.md b/docs/providers/pi.md index decef2bc6cd..5f216c07811 100644 --- a/docs/providers/pi.md +++ b/docs/providers/pi.md @@ -20,7 +20,7 @@ T3 does not copy or replace Pi configuration. Each thread starts Pi in that thre T3 keeps one long-lived `pi --mode rpc` process per active thread. It starts Pi with `--approve`, uses LF-delimited JSON RPC framing, and persists Pi's authoritative session id so a thread can resume after the server restarts. Model and reasoning-level choices come from Pi's own model registry and authentication state. T3 loads Pi extensions before discovery, so extension-registered providers such as `claude-agent-sdk` appear alongside built-in providers. Only models with configured credentials are shown, and each picker row identifies the underlying Pi model provider. Credentials are not returned to the client. -The composer settings control beside the model picker exposes the selected model's reasoning levels. When the active profile provides the compatible `effort-commands` extension, the same control also exposes context-window choices and Codex Fast service for supported OpenAI Codex models. T3 applies model, reasoning, context, and service-tier changes in order before sending the user prompt. Profile selection remains available there for new drafts. +The composer settings control beside the model picker exposes the selected model's reasoning levels. When the configured profile provides the compatible `effort-commands` extension, the same control also exposes context-window choices and Codex Fast service for supported OpenAI Codex models. T3 applies model, reasoning, context, and service-tier changes in order before sending the user prompt. Profile selection remains available there for new drafts. Because another profile can load different extensions, T3 revalidates `/context` and `/fast` against the live session; unsupported selections are ignored with a runtime warning instead of preventing the thread from starting. Extension confirmation prompts are approved automatically for the full-access runtime. Prompts that require fabricated text input are cancelled instead.