diff --git a/apps/mobile/src/components/ComposerModelControl.tsx b/apps/mobile/src/components/ComposerModelControl.tsx index e422cfabd459..b090a24faeb3 100644 --- a/apps/mobile/src/components/ComposerModelControl.tsx +++ b/apps/mobile/src/components/ComposerModelControl.tsx @@ -28,7 +28,6 @@ export function ComposerModelControl(props: { const promptTraits = getPromptControlledTraits(descriptors, props.prompt); const summary = props.provider ? buildTraitsTriggerDisplay({ - provider: props.provider, descriptors, primarySelectDescriptorId: promptTraits.primarySelectDescriptor?.id ?? null, ultrathinkPromptControlled: promptTraits.ultrathinkPromptControlled, diff --git a/apps/web/src/components/chat/ComposerModelEffort.tsx b/apps/web/src/components/chat/ComposerModelEffort.tsx index 10a4f7fa9189..f56997ff2a6a 100644 --- a/apps/web/src/components/chat/ComposerModelEffort.tsx +++ b/apps/web/src/components/chat/ComposerModelEffort.tsx @@ -36,16 +36,12 @@ import { type SelectDescriptor = Extract; type BooleanDescriptor = Extract; -/** Codex service tiers, Claude's Fast mode, and Ultracode sit in the footer, not the column. */ +/** Service tiers, Claude's Fast mode, and Ultracode sit in the footer, not the column. */ function splitSpeed( - provider: TraitsSelectionInput["provider"], selects: ReadonlyArray, toggles: ReadonlyArray, ) { - const speedSelect = - provider === "codex" - ? (selects.find((descriptor) => descriptor.id === "serviceTier") ?? null) - : null; + const speedSelect = selects.find((descriptor) => descriptor.id === "serviceTier") ?? null; const fastToggle = toggles.find((descriptor) => descriptor.id === "fastMode") ?? null; const ultracodeToggle = toggles.find((descriptor) => descriptor.id === "ultracode") ?? null; return { @@ -68,11 +64,10 @@ type ColumnControl = * such as an agent's session mode, sit below as one row each. */ function columnControls( - provider: TraitsSelectionInput["provider"], selects: ReadonlyArray, toggles: ReadonlyArray, ) { - const { columnSelects, columnToggles } = splitSpeed(provider, selects, toggles); + const { columnSelects, columnToggles } = splitSpeed(selects, toggles); const controls: ReadonlyArray = [ ...columnSelects.map((descriptor) => ({ kind: "select" as const, descriptor })), ...columnToggles.map((descriptor) => ({ kind: "boolean" as const, descriptor })), @@ -100,7 +95,6 @@ export function describeComposerEffort( : null; const primary = visibility.primarySelectDescriptor; const display = buildTraitsTriggerDisplay({ - provider: traits.provider, descriptors: visibility.descriptors, primarySelectDescriptorId: primary?.id ?? null, ultrathinkPromptControlled: visibility.ultrathinkPromptControlled, @@ -109,8 +103,8 @@ export function describeComposerEffort( }); // The same reasoning control the column lists, wherever the agent put it among its options. const reasoning = - columnControls(traits.provider, visibility.selectDescriptors, visibility.booleanDescriptors) - .effort?.descriptor ?? null; + columnControls(visibility.selectDescriptors, visibility.booleanDescriptors).effort + ?.descriptor ?? null; const ultrathink = visibility.ultrathinkPromptControlled && reasoning === primary; const glyph = reasoning?.type === "select" @@ -253,11 +247,7 @@ function moveBetweenRows(event: KeyboardEvent) { function EffortLevels(props: { traits: TraitsSelectionInput }) { const traits = useTraitsSelection(props.traits); - const { effort, others } = columnControls( - props.traits.provider, - traits.selectDescriptors, - traits.booleanDescriptors, - ); + const { effort, others } = columnControls(traits.selectDescriptors, traits.booleanDescriptors); if (traits.modelIsUnavailable) { return ( <> @@ -505,11 +495,7 @@ export function ComposerEffortColumn(props: { traits: TraitsSelectionInput | nul const visibility = props.traits ? getTraitsSectionVisibility(props.traits) : null; const controls = props.traits && visibility - ? columnControls( - props.traits.provider, - visibility.selectDescriptors, - visibility.booleanDescriptors, - ) + ? columnControls(visibility.selectDescriptors, visibility.booleanDescriptors) : null; return ( <> @@ -573,7 +559,6 @@ function SpeedSegment(props: { function SpeedControl(props: { traits: TraitsSelectionInput }) { const traits = useTraitsSelection(props.traits); const { speedSelect, fastToggle } = splitSpeed( - props.traits.provider, traits.selectDescriptors, traits.booleanDescriptors, ); @@ -610,11 +595,7 @@ function SpeedControl(props: { traits: TraitsSelectionInput }) { function UltracodeControl(props: { traits: TraitsSelectionInput }) { const traits = useTraitsSelection(props.traits); - const { ultracodeToggle } = splitSpeed( - props.traits.provider, - traits.selectDescriptors, - traits.booleanDescriptors, - ); + const { ultracodeToggle } = splitSpeed(traits.selectDescriptors, traits.booleanDescriptors); if (traits.modelIsUnavailable || !ultracodeToggle) return null; const on = ultracodeToggle.currentValue === true; return ( diff --git a/apps/web/src/components/chat/SubagentTooltipContent.tsx b/apps/web/src/components/chat/SubagentTooltipContent.tsx index 5b5f8b347742..50b50273ba96 100644 --- a/apps/web/src/components/chat/SubagentTooltipContent.tsx +++ b/apps/web/src/components/chat/SubagentTooltipContent.tsx @@ -84,19 +84,18 @@ export function SubagentTooltipContent(props: { descriptor.type === "boolean" && typeof saved?.value === "boolean" ) { - return getTraitsSpeedDisplay(provider.driver, { + return getTraitsSpeedDisplay({ ...descriptor, currentValue: saved.value, }); } if ( - provider.driver === "codex" && descriptor.id === "serviceTier" && descriptor.type === "select" && typeof saved?.value === "string" && descriptor.options.some((option) => option.id === saved.value) ) { - return getTraitsSpeedDisplay(provider.driver, { + return getTraitsSpeedDisplay({ ...descriptor, currentValue: saved.value, }); diff --git a/apps/web/src/components/chat/TraitsPicker.tsx b/apps/web/src/components/chat/TraitsPicker.tsx index e5384e2b777f..51e74d25b8e5 100644 --- a/apps/web/src/components/chat/TraitsPicker.tsx +++ b/apps/web/src/components/chat/TraitsPicker.tsx @@ -495,7 +495,6 @@ export const TraitsPicker = memo(function TraitsPicker({ return null; } const { label: triggerLabel } = buildTraitsTriggerDisplay({ - provider: props.provider, descriptors: traits.descriptors, primarySelectDescriptorId: traits.primarySelectDescriptor?.id ?? null, ultrathinkPromptControlled: traits.ultrathinkPromptControlled, diff --git a/apps/web/src/components/chat/TraitsSpeed.tsx b/apps/web/src/components/chat/TraitsSpeed.tsx index 6fdc71cc395c..77b242827bf1 100644 --- a/apps/web/src/components/chat/TraitsSpeed.tsx +++ b/apps/web/src/components/chat/TraitsSpeed.tsx @@ -6,7 +6,6 @@ import { cn } from "~/lib/utils"; import { ComposerControlIcon, type ComposerControlSize } from "./ComposerControl"; export function getTraitsSpeedDisplay( - provider: ProviderDriverKind, descriptor: ProviderOptionDescriptor, ): { label: string; speedIcon: "fast" | "ultrafast" | null } | null { if (descriptor.id === "fastMode" && descriptor.type === "boolean") { @@ -15,7 +14,7 @@ export function getTraitsSpeedDisplay( speedIcon: descriptor.currentValue === true ? "fast" : null, }; } - if (provider !== "codex" || descriptor.id !== "serviceTier" || descriptor.type !== "select") { + if (descriptor.id !== "serviceTier" || descriptor.type !== "select") { return null; } const currentValue = getProviderOptionCurrentValue(descriptor); diff --git a/docs/user/providers-pi.md b/docs/user/providers-pi.md index d397fe79dcbd..e6a7b7d24402 100644 --- a/docs/user/providers-pi.md +++ b/docs/user/providers-pi.md @@ -37,7 +37,10 @@ update it through that manager instead. For example, a Nix profile installation T3 Code discovers the models reported by Pi and exposes their supported thinking levels. The thinking picker marks the discovered active model's configured level as its default without -overriding it. Other models keep their Pi defaults when no level is selected. Threads +overriding it. Other models keep their Pi defaults when no level is selected. A model that lists +`serviceTiers` (for example `[{ "id": "priority", "name": "Fast" }]`) in its Pi definition gets +a speed picker with Standard plus those tiers. A chosen tier is sent as the request's +`service_tier`; Standard sends none and leaves the endpoint's default. Threads use Pi's native session files for resume, rollback, and forks within the same Pi instance. Forks preserve the native conversation through the selected turn in the destination workspace. Switching providers uses portable conversation context. Extension diff --git a/packages/client-runtime/src/modelTraits.test.ts b/packages/client-runtime/src/modelTraits.test.ts index 67b22e309946..24da035532a0 100644 --- a/packages/client-runtime/src/modelTraits.test.ts +++ b/packages/client-runtime/src/modelTraits.test.ts @@ -1,4 +1,4 @@ -import { ProviderDriverKind, type ProviderOptionDescriptor } from "@t3tools/contracts"; +import type { ProviderOptionDescriptor } from "@t3tools/contracts"; import { describe, expect, it } from "vite-plus/test"; import { buildTraitsTriggerDisplay, getDescriptorStringValue } from "./modelTraits.ts"; @@ -25,7 +25,6 @@ describe("buildTraitsTriggerDisplay", () => { expect( buildTraitsTriggerDisplay({ - provider: ProviderDriverKind.make("pi"), descriptors: [descriptor], primarySelectDescriptorId: descriptor.id, ultrathinkPromptControlled: false, diff --git a/packages/client-runtime/src/modelTraits.ts b/packages/client-runtime/src/modelTraits.ts index 3782b79e567f..6e84acf10fc0 100644 --- a/packages/client-runtime/src/modelTraits.ts +++ b/packages/client-runtime/src/modelTraits.ts @@ -1,8 +1,4 @@ -import type { - ModelSelection, - ProviderDriverKind, - ProviderOptionDescriptor, -} from "@t3tools/contracts"; +import type { ModelSelection, ProviderOptionDescriptor } from "@t3tools/contracts"; import { buildProviderOptionSelectionsFromDescriptors, getProviderOptionCurrentLabel, @@ -110,7 +106,6 @@ export function getTraitSelectChange( } export function buildTraitsTriggerDisplay(input: { - provider: ProviderDriverKind; descriptors: ReadonlyArray; primarySelectDescriptorId: string | null; ultrathinkPromptControlled: boolean; @@ -140,11 +135,7 @@ export function buildTraitsTriggerDisplay(input: { } continue; } - if ( - input.provider === "codex" && - descriptor.id === "serviceTier" && - descriptor.type === "select" - ) { + if (descriptor.id === "serviceTier" && descriptor.type === "select") { const currentValue = getProviderOptionCurrentValue(descriptor); const fastTier = descriptor.options.find(({ label }) => label === "Fast"); const ultrafastTier = descriptor.options.find(({ label }) => label === "Ultrafast"); diff --git a/packages/provider-pi/src/server/adapter.test.ts b/packages/provider-pi/src/server/adapter.test.ts index 67e7cc6aca8e..06ad17f0568b 100644 --- a/packages/provider-pi/src/server/adapter.test.ts +++ b/packages/provider-pi/src/server/adapter.test.ts @@ -53,6 +53,7 @@ import { } from "./adapter.ts"; import { makePiRpcConnection, type PiRpcRecord } from "./rpc.ts"; import { loadMcpBridge } from "./mcpBridge.testkit.ts"; +import { PI_T3_SERVICE_TIER_COMMAND } from "./mcpExtensionSource.ts"; import { turnItemOutputText } from "../../../client-runtime/src/work-log/itemDetail.ts"; const layerTest = Layer.mergeAll( @@ -173,6 +174,12 @@ interface FakePi { readonly allRequests: () => ReadonlyArray; readonly promptThinkingLevels: ReadonlyArray; readonly promptModels: ReadonlyArray; + /** T3 extension's service tier as each prompt was sent; Pi resets it with each session. */ + readonly promptServiceTiers: ReadonlyArray; + /** Pi reloads extensions without the adapter seeing it, e.g. a user extension's ctx.newSession(). */ + readonly reloadExtensions: () => void; + /** Pi before 0.99 acknowledges prompts without a disposition. */ + readonly omitPromptDisposition: () => void; readonly queueStats: (data: unknown) => void; /** Pi before 0.84.4 answers clear_queue as an unknown command. */ readonly setClearQueue: ( @@ -230,7 +237,11 @@ const makeFakePi: Effect.Effect = Effect.gen(function* () { let thinkingLevel = "high"; const promptThinkingLevels: Array = []; const promptModels: Array = []; + const promptServiceTiers: Array = []; + let serviceTier: string | null = null; + let promptDisposition = true; let currentModel = "default"; + const serviceTierCommand = `/${PI_T3_SERVICE_TIER_COMMAND}`; const emit = (record: PiRpcRecord, chunkBytes?: number) => Effect.gen(function* () { @@ -280,6 +291,10 @@ const makeFakePi: Effect.Effect = Effect.gen(function* () { thinkingLevel = String(record["level"]); return base; case "prompt": + if (String(record["message"]).startsWith(serviceTierCommand)) { + serviceTier = String(record["message"]).slice(serviceTierCommand.length).trim() || null; + return promptDisposition ? { ...base, data: { disposition: "handled" } } : base; + } if (isCompacting) { return { ...base, success: false, error: PI_COMPACTING_PROMPT_ERROR }; } @@ -297,12 +312,16 @@ const makeFakePi: Effect.Effect = Effect.gen(function* () { case "new_session": { const cancelled = vetoNewSession; vetoNewSession = false; - if (!cancelled) sessionFile = `/fake/new-${++sessionGeneration}.jsonl`; + if (!cancelled) { + sessionFile = `/fake/new-${++sessionGeneration}.jsonl`; + serviceTier = null; + } return { ...base, data: { cancelled } }; } case "switch_session": { const cancelled = vetoSwitch; vetoSwitch = false; + if (!cancelled) serviceTier = null; return { ...base, data: { cancelled } }; } case "get_entries": @@ -312,6 +331,7 @@ const makeFakePi: Effect.Effect = Effect.gen(function* () { case "get_session_stats": return { ...base, data: statsQueue.shift() ?? {} }; case "fork": + serviceTier = null; return { ...base, data: { text: "Hello pi", cancelled: false } }; case "clear_queue": return clearQueue === "unknown_command" @@ -332,9 +352,13 @@ const makeFakePi: Effect.Effect = Effect.gen(function* () { stdinBuffer = stdinBuffer.slice(newline + 1); if (line.length === 0) continue; const record = decodeJsonLine(line) as PiRpcRecord; - if (record["type"] === "prompt") { + if ( + record["type"] === "prompt" && + !String(record["message"]).startsWith(serviceTierCommand) + ) { promptThinkingLevels.push(thinkingLevel); promptModels.push(currentModel); + promptServiceTiers.push(serviceTier); } allRequests.push(record); yield* Queue.offer(requests, record); @@ -395,6 +419,13 @@ const makeFakePi: Effect.Effect = Effect.gen(function* () { }, promptThinkingLevels, promptModels, + promptServiceTiers, + reloadExtensions: () => { + serviceTier = null; + }, + omitPromptDisposition: () => { + promptDisposition = false; + }, emit, takeRequest, queueEntries: (data) => entriesQueue.push(data), @@ -2293,6 +2324,68 @@ describe("PiAdapterV2", () => { }).pipe(Effect.scoped, Effect.provide(layerTest)), ); + it.effect("applies the selected service tier without changing thinking", () => + Effect.gen(function* () { + const fake = yield* makeFakePi; + const { runtime, takeEvent } = yield* openRuntime(fake); + const providerThread = yield* runtime.ensureThread({ + threadId: THREAD_ID, + modelSelection: modelSelection("default"), + runtimePolicy, + }); + const turn = (text: string, serviceTier: string | undefined, ordinal: number) => + Effect.gen(function* () { + yield* startTurn( + runtime, + providerThread, + "default", + [], + text, + { + instanceId: PI_INSTANCE_ID, + model: "columbia-codex/gpt-6.1-sol", + options: [ + { id: "thinking", value: "high" }, + ...(serviceTier === undefined ? [] : [{ id: "serviceTier", value: serviceTier }]), + ], + }, + ordinal, + ); + while (String((yield* fake.takeRequest("prompt"))["message"]).startsWith("/")) {} + 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* turn("Fast", "priority", 1); + fake.reloadExtensions(); + yield* turn("Still fast", "priority", 2); + yield* turn("Ultrafast", "ultrafast", 3); + fake.omitPromptDisposition(); + yield* turn("Standard", "default", 4); + yield* turn("Unset", undefined, 5); + + assert.deepEqual(fake.promptServiceTiers, ["priority", "priority", "ultrafast", null, null]); + assert.deepEqual(fake.promptThinkingLevels, ["high", "high", "high", "high", "high"]); + assert.deepEqual( + fake + .allRequests() + .flatMap((request) => + String(request["message"]).startsWith(`/${PI_T3_SERVICE_TIER_COMMAND}`) + ? [request["message"]] + : [], + ), + [ + `/${PI_T3_SERVICE_TIER_COMMAND} priority`, + `/${PI_T3_SERVICE_TIER_COMMAND} priority`, + `/${PI_T3_SERVICE_TIER_COMMAND} ultrafast`, + `/${PI_T3_SERVICE_TIER_COMMAND}`, + ], + ); + }).pipe(Effect.scoped, Effect.provide(layerTest)), + ); + it.effect.each([false, true, "previous"] as const)( "resets applied thinking when returning to Pi default (failed first restore: %s)", (failFirstRestore) => diff --git a/packages/provider-pi/src/server/adapter.ts b/packages/provider-pi/src/server/adapter.ts index 8ed02b6917fc..a82a7f45f4e4 100644 --- a/packages/provider-pi/src/server/adapter.ts +++ b/packages/provider-pi/src/server/adapter.ts @@ -71,7 +71,8 @@ import { materializePiT3McpExtension, resolvePiLaunchArgs, } from "./mcpInjection.ts"; -import { PI_FILE_CHANGE_TOOLS } from "./mcpExtensionSource.ts"; +import { PI_FILE_CHANGE_TOOLS, PI_T3_SERVICE_TIER_COMMAND } from "./mcpExtensionSource.ts"; +import { PI_STANDARD_SERVICE_TIER } from "./modelCapabilities.ts"; export const PI_PROVIDER = ProviderDriverKind.make("pi"); const PI_DRIVER_KIND = PI_PROVIDER; @@ -575,6 +576,9 @@ export const makePiAdapterV2 = Effect.fn("makePiAdapterV2")(function* ( ); let appliedModel: string | null = null; let appliedThinking: string | null = null; + // Pi reloads extensions (resetting the tier) on paths the adapter cannot see, so a chosen tier + // is resent every turn; Standard sends a clear only when an earlier tier may still be set. + let serviceTierMayBeSet = false; let appliedSessionName: string | null = null; const outOfTurnExtensionErrors: Array = []; let lastKnownLeaf: string | null = null; @@ -2200,6 +2204,8 @@ export const makePiAdapterV2 = Effect.fn("makePiAdapterV2")(function* ( } return; } + // Only id-less prompts own ACK slots; a late reply to an id-bearing request must not take one. + if (command === "prompt" && typeof event["id"] === "string") return; const pendingPrompt = command === "prompt" ? pendingPromptResponses.shift() : undefined; const responseTurn = pendingPrompt?.providerTurnId === turn?.providerTurn.id ? turn : null; @@ -2598,6 +2604,24 @@ export const makePiAdapterV2 = Effect.fn("makePiAdapterV2")(function* ( yield* request({ type: "set_thinking_level", level: thinking }); appliedThinking = thinking; } + const requestedTier = getModelSelectionStringOptionValue(modelSelection, "serviceTier"); + const serviceTier = + requestedTier === undefined || requestedTier === PI_STANDARD_SERVICE_TIER + ? null + : requestedTier; + if (serviceTier !== null || serviceTierMayBeSet) { + serviceTierMayBeSet = true; + const result = yield* request({ + type: "prompt", + message: `/${PI_T3_SERVICE_TIER_COMMAND} ${serviceTier ?? ""}`.trimEnd(), + }); + // Pi before 0.99 acknowledges a handled extension command without a disposition. + const disposition = recordString(result, "disposition"); + if (disposition !== undefined && disposition !== "handled") { + return yield* protocolError("T3 Code's Pi extension did not apply the service tier"); + } + serviceTierMayBeSet = serviceTier !== null; + } }); const resolvePromptPayload = Effect.fnUntraced(function* ( diff --git a/packages/provider-pi/src/server/mcpBridge.testkit.ts b/packages/provider-pi/src/server/mcpBridge.testkit.ts index 133a4b2981ac..505abd4735ff 100644 --- a/packages/provider-pi/src/server/mcpBridge.testkit.ts +++ b/packages/provider-pi/src/server/mcpBridge.testkit.ts @@ -125,6 +125,7 @@ export async function loadMcpBridge( }, pi: { on: (name: string, handler: AgentStartHook) => handlers.set(name, handler), + registerCommand: () => {}, registerTool: (tool: RegisteredTool) => { if (options.allowsTool && !options.allowsTool(tool.name)) return; const index = tools.findIndex((current) => current.name === tool.name); diff --git a/packages/provider-pi/src/server/mcpExtensionSource.test.ts b/packages/provider-pi/src/server/mcpExtensionSource.test.ts index cedf3974f535..1e2ac44cf4b5 100644 --- a/packages/provider-pi/src/server/mcpExtensionSource.test.ts +++ b/packages/provider-pi/src/server/mcpExtensionSource.test.ts @@ -6,14 +6,16 @@ import * as NodeModule from "node:module"; import * as NodeVM from "node:vm"; import { assert, describe, it } from "@effect/vitest"; -import { PI_T3_MCP_EXTENSION_SOURCE } from "./mcpExtensionSource.ts"; +import { PI_T3_MCP_EXTENSION_SOURCE, PI_T3_SERVICE_TIER_COMMAND } from "./mcpExtensionSource.ts"; import { loadMcpBridge } from "./mcpBridge.testkit.ts"; type RequestHook = ( event: { payload: unknown }, - ctx: { model: { provider: string } }, + ctx: { model: { provider: string; serviceTiers?: ReadonlyArray<{ id: string }> } }, ) => Record | undefined; +type CommandHandler = (args: string) => Promise; + interface SkillCommand { readonly name: string; readonly source: string; @@ -43,6 +45,7 @@ async function loadHooks( stripFrontmatter: (content: string) => content, pi: { on: (name: string, handler: unknown) => handlers.set(name, handler), + registerCommand: () => {}, getCommands: () => commands, }, }); @@ -367,6 +370,7 @@ describe("Pi tool discovery permissions", () => { on: (name: string, handler: ToolCallHook) => { if (name === "tool_call") toolCall = handler; }, + registerCommand: () => {}, getAllTools: () => [{ name: "tool_search", sourceInfo: { path: searchPath } }], }, }); @@ -398,8 +402,9 @@ describe("Pi tool discovery permissions", () => { }); }); -async function loadRequestHook(): Promise { +async function loadRequestHook() { const handlers = new Map(); + const commands = new Map(); const source = NodeModule.stripTypeScriptTypes( PI_T3_MCP_EXTENSION_SOURCE.replace(/^import .*;$/gm, "").replace( "export default async function", @@ -408,13 +413,43 @@ async function loadRequestHook(): Promise { ); await NodeVM.runInNewContext(`${source}\nt3McpExtension(pi)`, { process: { env: {} }, - pi: { on: (name: string, handler: RequestHook) => handlers.set(name, handler) }, + pi: { + on: (name: string, handler: RequestHook) => handlers.set(name, handler), + registerCommand: (name: string, options: { handler: CommandHandler }) => + commands.set(name, options.handler), + }, }); const hook = handlers.get("before_provider_request"); + const setServiceTier = commands.get(PI_T3_SERVICE_TIER_COMMAND); assert.isDefined(hook); - return hook!; + assert.isDefined(setServiceTier); + return Object.assign(hook!, { setServiceTier: setServiceTier! }); } +describe("Pi service tier", () => { + it("sends only a tier the active model advertises, until Standard clears it", async () => { + const hook = await loadRequestHook(); + const sol = { + provider: "columbia-codex", + serviceTiers: [{ id: "priority" }, { id: "ultrafast" }], + }; + const payload = { model: "gpt-6.1-sol", reasoning: { effort: "high" } }; + assert.isUndefined(hook({ payload }, { model: sol })); + + await hook.setServiceTier("priority"); + assert.deepEqual(hook({ payload }, { model: sol }), { ...payload, service_tier: "priority" }); + + await hook.setServiceTier("ultrafast"); + assert.equal(hook({ payload }, { model: sol })?.service_tier, "ultrafast"); + const fastOnly = { provider: "columbia-codex", serviceTiers: [{ id: "priority" }] }; + assert.isUndefined(hook({ payload }, { model: fastOnly })); + assert.isUndefined(hook({ payload }, { model: { provider: "anthropic" } })); + + await hook.setServiceTier(""); + assert.isUndefined(hook({ payload }, { model: sol })); + }); +}); + describe("Pi upstream output-budget workaround", () => { it.each(["max_tokens", "max_completion_tokens"])( "caps %s without changing the conversation or tools", diff --git a/packages/provider-pi/src/server/mcpExtensionSource.ts b/packages/provider-pi/src/server/mcpExtensionSource.ts index ee72c38f7596..c63e60feb9e4 100644 --- a/packages/provider-pi/src/server/mcpExtensionSource.ts +++ b/packages/provider-pi/src/server/mcpExtensionSource.ts @@ -7,6 +7,7 @@ export const T3_MCP_URL_ENV = "T3_MCP_URL"; export const T3_MCP_BEARER_ENV = "T3_MCP_BEARER_TOKEN"; export const T3_PI_RUNTIME_MODE_ENV = "T3_PI_RUNTIME_MODE"; export const T3_PI_MCP_EXTENSION_PATH_ENV = "T3_PI_MCP_EXTENSION_PATH"; +export const PI_T3_SERVICE_TIER_COMMAND = "t3-service-tier"; // These tools trigger file-change approvals, bypassed by Auto-accept edits. export const PI_FILE_CHANGE_TOOLS = ["edit", "write"] as const; @@ -22,6 +23,7 @@ const TOKEN_ENV = ${JSON.stringify(T3_MCP_BEARER_ENV)}; const RUNTIME_MODE_ENV = ${JSON.stringify(T3_PI_RUNTIME_MODE_ENV)}; const EXTENSION_PATH_ENV = ${JSON.stringify(T3_PI_MCP_EXTENSION_PATH_ENV)}; const ORCHESTRATION_INSTRUCTIONS = ${JSON.stringify(T3_CODE_ORCHESTRATION_INSTRUCTIONS.trim())}; +const SERVICE_TIER_COMMAND = ${JSON.stringify(PI_T3_SERVICE_TIER_COMMAND)}; const PROTOCOL = "2025-06-18"; const READ_ONLY_TOOLS = new Set(["read", "grep", "find", "ls"]); const FILE_CHANGE_TOOLS = new Set(${JSON.stringify(PI_FILE_CHANGE_TOOLS)}); @@ -287,24 +289,44 @@ export default async function t3McpExtension(pi: ExtensionAPI) { }; }); - // Workaround for an upstream Pi context-budgeting bug: pi-ai reuses the - // previous response's usage even when a fork's instructions/tools differ, - // then reserves almost all remaining context for output. OpenRouter can - // reject even a short conversation. Remove this cap when Pi accounts for - // the current request prefix reliably (api/simple-options + utils/estimate). + // Pi has no service tier setting, so T3 sets one through this command before each turn. + // Only a tier the active model advertises is sent; Standard sends none. + let serviceTier: string | undefined; + pi.registerCommand(SERVICE_TIER_COMMAND, { + description: "Set the service tier T3 Code sends with provider requests", + handler: async (args) => { + serviceTier = args.trim() || undefined; + }, + }); + pi.on("before_provider_request", (event, ctx) => { - if (ctx.model?.provider !== "openrouter") return; const payload = event.payload; if (typeof payload !== "object" || payload === null || Array.isArray(payload)) return; const replacement = { ...payload } as Record; let changed = false; - for (const key of ["max_tokens", "max_completion_tokens"]) { - const limit = replacement[key]; - if (typeof limit === "number" && Number.isFinite(limit) && limit > 32_768) { - replacement[key] = 32_768; - changed = true; + // Workaround for an upstream Pi context-budgeting bug: pi-ai reuses the + // previous response's usage even when a fork's instructions/tools differ, + // then reserves almost all remaining context for output. OpenRouter can + // reject even a short conversation. Remove this cap when Pi accounts for + // the current request prefix reliably (api/simple-options + utils/estimate). + if (ctx.model?.provider === "openrouter") { + for (const key of ["max_tokens", "max_completion_tokens"]) { + const limit = replacement[key]; + if (typeof limit === "number" && Number.isFinite(limit) && limit > 32_768) { + replacement[key] = 32_768; + changed = true; + } } } + const tiers = (ctx.model as { serviceTiers?: unknown } | undefined)?.serviceTiers; + if ( + serviceTier !== undefined && + Array.isArray(tiers) && + tiers.some((tier) => tier?.id === serviceTier) + ) { + replacement.service_tier = serviceTier; + changed = true; + } if (changed) return replacement; }); diff --git a/packages/provider-pi/src/server/thinkingCapabilities.ts b/packages/provider-pi/src/server/modelCapabilities.ts similarity index 54% rename from packages/provider-pi/src/server/thinkingCapabilities.ts rename to packages/provider-pi/src/server/modelCapabilities.ts index 139c698e8e85..2d379286c36f 100644 --- a/packages/provider-pi/src/server/thinkingCapabilities.ts +++ b/packages/provider-pi/src/server/modelCapabilities.ts @@ -1,4 +1,8 @@ -import { type ModelCapabilities, type ProviderOptionChoice } from "@t3tools/contracts"; +import { + type ModelCapabilities, + type ProviderOptionChoice, + type ProviderOptionDescriptor, +} from "@t3tools/contracts"; import { createModelCapabilities } from "@t3tools/shared/model"; import * as Predicate from "effect/Predicate"; @@ -21,27 +25,68 @@ export const EMPTY_PI_MODEL_CAPABILITIES: ModelCapabilities = createModelCapabil optionDescriptors: [], }); -export function thinkingCapabilitiesForPiModel( +/** Selecting Standard sends no `service_tier`, leaving the endpoint's default. */ +export const PI_STANDARD_SERVICE_TIER = "default"; + +export function piModelCapabilities( model: unknown, defaultThinkingLevel: unknown, ): ModelCapabilities { + const optionDescriptors = [ + thinkingDescriptor(model, defaultThinkingLevel), + serviceTierDescriptor(model), + ].filter(Predicate.isNotUndefined); + return optionDescriptors.length === 0 + ? EMPTY_PI_MODEL_CAPABILITIES + : createModelCapabilities({ optionDescriptors }); +} + +function thinkingDescriptor( + model: unknown, + defaultThinkingLevel: unknown, +): ProviderOptionDescriptor | undefined { const levels = supportedPiThinkingLevelsFromModel(model); - if (levels.length === 0) return EMPTY_PI_MODEL_CAPABILITIES; + if (levels.length === 0) return undefined; const defaultLevel = clampPiThinkingLevel(defaultThinkingLevel, levels); - return createModelCapabilities({ - optionDescriptors: [ - { - id: "thinking", - label: "Thinking", - type: "select", - options: levels.map((level): ProviderOptionChoice => ({ - id: level, - label: PI_THINKING_LEVEL_LABELS[level], - ...(level === defaultLevel ? { isDefault: true } : {}), - })), - }, - ], - }); + return { + id: "thinking", + label: "Thinking", + type: "select", + options: levels.map((level): ProviderOptionChoice => ({ + id: level, + label: PI_THINKING_LEVEL_LABELS[level], + ...(level === defaultLevel ? { isDefault: true } : {}), + })), + }; +} + +// Pi has no service tier concept; provider extensions advertise `serviceTiers` on a model, +// and T3's injected extension sends the chosen id as the request's `service_tier`. +function serviceTierDescriptor(model: unknown): ProviderOptionDescriptor | undefined { + const tiers = recordField(model, "serviceTiers"); + if (!Array.isArray(tiers)) return undefined; + const seen = new Set([PI_STANDARD_SERVICE_TIER]); + const options: Array = []; + for (const tier of tiers) { + const id = recordField(tier, "id"); + if (typeof id !== "string" || id.trim() === "" || seen.has(id)) continue; + seen.add(id); + const name = recordField(tier, "name"); + const description = recordField(tier, "description"); + options.push({ + id, + label: typeof name === "string" && name.trim() !== "" ? name : id, + ...(typeof description === "string" && description.trim() !== "" ? { description } : {}), + }); + } + if (options.length === 0) return undefined; + return { + id: "serviceTier", + label: "Service Tier", + type: "select", + options: [{ id: PI_STANDARD_SERVICE_TIER, label: "Standard", isDefault: true }, ...options], + currentValue: PI_STANDARD_SERVICE_TIER, + }; } function clampPiThinkingLevel( diff --git a/packages/provider-pi/src/server/status.test.ts b/packages/provider-pi/src/server/status.test.ts index 55caff62eac8..2b887500f302 100644 --- a/packages/provider-pi/src/server/status.test.ts +++ b/packages/provider-pi/src/server/status.test.ts @@ -276,6 +276,49 @@ describe("PiProvider", () => { }).pipe(Effect.provide(NodeServices.layer)), ); + it.effect("advertises a model's service tiers beside its thinking levels", () => + Effect.gen(function* () { + const spawner = yield* piDiscoverySpawner({ + models: [ + { + provider: "columbia-codex", + id: "gpt-6.1-sol", + reasoning: true, + serviceTiers: [ + { id: "priority", name: "Fast", description: "2x speed, increased usage" }, + { id: "ultrafast", name: "Ultrafast" }, + ], + }, + { provider: "anthropic", id: "claude-sonnet-4", reasoning: true }, + ], + }); + const snapshot = yield* checkPiProviderStatus(settings).pipe( + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + ); + const descriptors = (slug: string) => + snapshot.models.find((model) => model.slug === slug)?.capabilities?.optionDescriptors ?? []; + assert.deepEqual( + descriptors("columbia-codex/gpt-6.1-sol").map((descriptor) => descriptor.id), + ["thinking", "serviceTier"], + ); + assert.deepEqual(descriptors("columbia-codex/gpt-6.1-sol")[1], { + id: "serviceTier", + label: "Service Tier", + type: "select", + options: [ + { id: "default", label: "Standard", isDefault: true }, + { id: "priority", label: "Fast", description: "2x speed, increased usage" }, + { id: "ultrafast", label: "Ultrafast" }, + ], + currentValue: "default", + }); + assert.deepEqual( + descriptors("anthropic/claude-sonnet-4").map((descriptor) => descriptor.id), + ["thinking"], + ); + }).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 4775689c8c2b..e70bd1ad804e 100644 --- a/packages/provider-pi/src/server/status.ts +++ b/packages/provider-pi/src/server/status.ts @@ -40,10 +40,7 @@ import { enrichProviderSnapshotWithVersionAdvisory, type ProviderMaintenanceCapabilities, } from "@t3tools/provider-core/server/maintenanceResolver"; -import { - EMPTY_PI_MODEL_CAPABILITIES, - thinkingCapabilitiesForPiModel, -} from "./thinkingCapabilities.ts"; +import { EMPTY_PI_MODEL_CAPABILITIES, piModelCapabilities } from "./modelCapabilities.ts"; import { parsePiDiscoveredCommands, withPiBuiltinSlashCommands, @@ -127,7 +124,7 @@ function parseDiscoveredModels( name: recordString(model, "name") ?? slug, subProvider: provider, isCustom: false, - capabilities: thinkingCapabilitiesForPiModel( + capabilities: piModelCapabilities( model, provider === activeProvider && id === activeModelId ? recordString(state, "thinkingLevel")