diff --git a/docs/extensions.md b/docs/extensions.md index 0da8c250e..9fd9e3b32 100644 --- a/docs/extensions.md +++ b/docs/extensions.md @@ -121,9 +121,12 @@ Core methods: - `getCommands` - `getSessionName`, `setSessionName` - `setModel`, `getThinkingLevel`, `setThinkingLevel` +- `getServiceTiers`, `setServiceTier` - `registerProvider` - `events` (shared event bus) +`getServiceTiers()` returns a detached snapshot of the session's live per-family tier map. `setServiceTier(family, tier)` changes one family for subsequent requests; pass `undefined` to clear that session override. OpenAI accepts `auto`, `default`, `flex`, `scale`, or `priority`; Anthropic accepts `priority`; Google accepts `flex` or `priority`. Changes made while a response is streaming do not alter that in-flight request. + In interactive mode, `input` handlers run before the built-in first-message auto-title check. Extensions that call `await pi.setSessionName(...)` from `input` can set the persisted session name and prevent the default auto-generated title from running for that session. Also exposed: diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 7677e9bb7..1df448327 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -19,6 +19,7 @@ - Added a toggle-list editor in `/settings` for array-of-enum settings: `providers.webSearchOrder` and `providers.imageOrder` (ordered — Enter/Space toggles, ←/→ nudges, 1-9 splices the hovered provider into that position) and `providers.webSearchExclude` now appear under Providers → Services instead of being config-file only. - Added `models.yml` Bedrock Converse prompt-cache capability overrides for bundled and opaque inference profiles. - Documented Vibe mode (`/vibe`) in `docs/vibe-mode.md` and the `/fresh` provider-stream reset in the session-operations doc, and linked both from the README's new "Session controls" section ([#6440](https://github.com/can1357/oh-my-pi/issues/6440)). +- Added `getServiceTiers()` and `setServiceTier()` extension APIs for reading and changing the live per-family service tier used by subsequent session requests ([#5860](https://github.com/can1357/oh-my-pi/issues/5860)). ### Changed diff --git a/packages/coding-agent/src/config/service-tier.ts b/packages/coding-agent/src/config/service-tier.ts index 74ce6d1f7..c6c3051c0 100644 --- a/packages/coding-agent/src/config/service-tier.ts +++ b/packages/coding-agent/src/config/service-tier.ts @@ -1,4 +1,4 @@ -import type { ServiceTier, ServiceTierByFamily } from "@oh-my-pi/pi-ai"; +import type { ServiceTier, ServiceTierByFamily, ServiceTierFamily } from "@oh-my-pi/pi-ai"; import type { SubmenuOption } from "./settings-schema"; /** @@ -16,6 +16,31 @@ export type ServiceTierOpenAISettingValue = (typeof SERVICE_TIER_OPENAI_VALUES)[ export type ServiceTierAnthropicSettingValue = (typeof SERVICE_TIER_ANTHROPIC_VALUES)[number]; export type ServiceTierGoogleSettingValue = (typeof SERVICE_TIER_GOOGLE_VALUES)[number]; +/** Whether a runtime value names a provider family with an independent service-tier knob. */ +export function isServiceTierFamily(value: unknown): value is ServiceTierFamily { + return value === "openai" || value === "anthropic" || value === "google"; +} + +/** Whether a runtime value is a supported service tier for one provider family. */ +export function isServiceTierForFamily(family: string, tier: unknown): tier is ServiceTier { + if (typeof tier !== "string" || tier === "none") return false; + let values: readonly string[]; + switch (family) { + case "openai": + values = SERVICE_TIER_OPENAI_VALUES; + break; + case "anthropic": + values = SERVICE_TIER_ANTHROPIC_VALUES; + break; + case "google": + values = SERVICE_TIER_GOOGLE_VALUES; + break; + default: + return false; + } + return values.includes(tier); +} + /** * Inherit-capable single value for the subagent/advisor tiers. The chosen tier * is broadcast across families and applied to whichever family the spawned diff --git a/packages/coding-agent/src/extensibility/extensions/loader.ts b/packages/coding-agent/src/extensibility/extensions/loader.ts index ec1705405..35d978110 100644 --- a/packages/coding-agent/src/extensibility/extensions/loader.ts +++ b/packages/coding-agent/src/extensibility/extensions/loader.ts @@ -5,13 +5,22 @@ import type * as fs1 from "node:fs"; import * as fs from "node:fs/promises"; import * as path from "node:path"; import type { ThinkingLevel } from "@oh-my-pi/pi-agent-core"; -import type { ImageContent, Model, TextContent, TSchema } from "@oh-my-pi/pi-ai"; +import type { + ImageContent, + Model, + ServiceTier, + ServiceTierByFamily, + ServiceTierFamily, + TextContent, + TSchema, +} from "@oh-my-pi/pi-ai"; import type { KeyId } from "@oh-my-pi/pi-tui"; import { hasFsCode, isEacces, isEnoent, logger } from "@oh-my-pi/pi-utils"; import { Type } from "arktype"; import * as zodModule from "zod/v4"; import { type ExtensionModule, extensionModuleCapability } from "../../capability/extension-module"; import { type Hook, hookCapability } from "../../capability/hook"; +import { isServiceTierFamily, isServiceTierForFamily } from "../../config/service-tier"; import { loadCapability } from "../../discovery"; import { getExtensionNameFromPath } from "../../discovery/helpers"; import type { ExecOptions } from "../../exec/exec"; @@ -107,6 +116,14 @@ export class ExtensionRuntime implements IExtensionRuntime { throw new ExtensionRuntimeNotInitializedError(); } + getServiceTiers(): ServiceTierByFamily { + throw new ExtensionRuntimeNotInitializedError(); + } + + setServiceTier(): void { + throw new ExtensionRuntimeNotInitializedError(); + } + getSessionName(): string | undefined { throw new ExtensionRuntimeNotInitializedError(); } @@ -252,6 +269,17 @@ class ConcreteExtensionAPI implements ExtensionAPI, IExtensionRuntime { this.runtime.setThinkingLevel(level, persist); } + getServiceTiers(): Readonly { + return { ...this.runtime.getServiceTiers() }; + } + + setServiceTier(family: ServiceTierFamily, tier: ServiceTier | undefined): void { + if (!isServiceTierFamily(family) || (tier !== undefined && !isServiceTierForFamily(family, tier))) { + throw new TypeError(`Invalid service tier "${String(tier)}" for family "${String(family)}"`); + } + this.runtime.setServiceTier(family, tier); + } + getSessionName(): string | undefined { return this.runtime.getSessionName(); } diff --git a/packages/coding-agent/src/extensibility/extensions/runner.ts b/packages/coding-agent/src/extensibility/extensions/runner.ts index 54240a32a..b0af988fb 100644 --- a/packages/coding-agent/src/extensibility/extensions/runner.ts +++ b/packages/coding-agent/src/extensibility/extensions/runner.ts @@ -298,6 +298,8 @@ export class ExtensionRunner { this.runtime.setModel = actions.setModel; this.runtime.getThinkingLevel = actions.getThinkingLevel; this.runtime.setThinkingLevel = actions.setThinkingLevel; + this.runtime.getServiceTiers = actions.getServiceTiers; + this.runtime.setServiceTier = actions.setServiceTier; this.runtime.getSessionName = actions.getSessionName; this.runtime.setSessionName = actions.setSessionName; diff --git a/packages/coding-agent/src/extensibility/extensions/types.ts b/packages/coding-agent/src/extensibility/extensions/types.ts index 9c038cc8d..1f782cc30 100644 --- a/packages/coding-agent/src/extensibility/extensions/types.ts +++ b/packages/coding-agent/src/extensibility/extensions/types.ts @@ -25,6 +25,9 @@ import type { Model, ModelSpec, ProviderResponseMetadata, + ServiceTier, + ServiceTierByFamily, + ServiceTierFamily, SimpleStreamOptions, Static, TextContent, @@ -1044,6 +1047,13 @@ export interface RegisteredCommand { // biome-ignore lint/suspicious/noConfusingVoidType: void allows bare return statements export type ExtensionHandler = (event: E, ctx: ExtensionContext) => Promise | R | void; +/** Service tiers accepted by each provider family. */ +export type ExtensionServiceTier = Family extends "anthropic" + ? "priority" + : Family extends "google" + ? "flex" | "priority" + : ServiceTier; + /** * ExtensionAPI passed to extension factory functions. */ @@ -1230,6 +1240,18 @@ export interface ExtensionAPI { /** Set thinking level for the current session. */ setThinkingLevel(level: ThinkingLevel): void; + /** Get a snapshot of the current session's per-family service tiers. */ + getServiceTiers(): Readonly; + + /** + * Set one provider family's service tier for subsequent requests, or clear + * its session override with `undefined`. + */ + setServiceTier( + family: Family, + tier: ExtensionServiceTier | undefined, + ): void; + /** Get the current session name. */ getSessionName(): string | undefined; @@ -1409,6 +1431,10 @@ export type GetThinkingLevelHandler = () => ThinkingLevel | undefined; export type SetThinkingLevelHandler = (level: ThinkingLevel, persist?: boolean) => void; +export type GetServiceTiersHandler = () => ServiceTierByFamily; + +export type SetServiceTierHandler = (family: ServiceTierFamily, tier: ServiceTier | undefined) => void; + /** Shared state created by loader, used during registration and runtime. */ export interface ExtensionRuntimeState { flagValues: Map; @@ -1429,6 +1455,8 @@ export interface ExtensionActions { setModel: SetModelHandler; getThinkingLevel: GetThinkingLevelHandler; setThinkingLevel: SetThinkingLevelHandler; + getServiceTiers: GetServiceTiersHandler; + setServiceTier: SetServiceTierHandler; getSessionName: () => string | undefined; setSessionName: (name: string) => Promise; } diff --git a/packages/coding-agent/src/modes/acp/acp-agent.ts b/packages/coding-agent/src/modes/acp/acp-agent.ts index 7d862fc26..1a623ae48 100644 --- a/packages/coding-agent/src/modes/acp/acp-agent.ts +++ b/packages/coding-agent/src/modes/acp/acp-agent.ts @@ -2311,6 +2311,8 @@ export class AcpAgent implements Agent { }, getThinkingLevel: () => record.session.thinkingLevel, setThinkingLevel: level => record.session.setThinkingLevel(level), + getServiceTiers: () => record.session.serviceTierByFamily, + setServiceTier: (family, tier) => record.session.setServiceTierFamily(family, tier), getSessionName: () => record.session.sessionManager.getSessionName(), setSessionName: async name => { await record.session.sessionManager.setSessionName(name, "user"); diff --git a/packages/coding-agent/src/modes/controllers/extension-ui-controller.ts b/packages/coding-agent/src/modes/controllers/extension-ui-controller.ts index 117f16c8f..dfce6e4b6 100644 --- a/packages/coding-agent/src/modes/controllers/extension-ui-controller.ts +++ b/packages/coding-agent/src/modes/controllers/extension-ui-controller.ts @@ -165,6 +165,8 @@ export class ExtensionUiController { }, getThinkingLevel: () => this.ctx.session.thinkingLevel, setThinkingLevel: level => this.ctx.session.setThinkingLevel(level), + getServiceTiers: () => this.ctx.session.serviceTierByFamily, + setServiceTier: (family, tier) => this.ctx.session.setServiceTierFamily(family, tier), getCommands: () => getSessionSlashCommands(this.ctx.session), getSessionName: () => this.ctx.sessionManager.getSessionName(), setSessionName: name => this.#updateSessionName(name), @@ -396,6 +398,8 @@ export class ExtensionUiController { }, getThinkingLevel: () => this.ctx.session.thinkingLevel, setThinkingLevel: (level, persist) => this.ctx.session.setThinkingLevel(level, persist), + getServiceTiers: () => this.ctx.session.serviceTierByFamily, + setServiceTier: (family, tier) => this.ctx.session.setServiceTierFamily(family, tier), getCommands: () => getSessionSlashCommands(this.ctx.session), getSessionName: () => this.ctx.sessionManager.getSessionName(), setSessionName: name => this.#updateSessionName(name), diff --git a/packages/coding-agent/src/modes/runtime-init.ts b/packages/coding-agent/src/modes/runtime-init.ts index 57d7775f2..2ac16d977 100644 --- a/packages/coding-agent/src/modes/runtime-init.ts +++ b/packages/coding-agent/src/modes/runtime-init.ts @@ -90,6 +90,8 @@ export async function initializeExtensions(session: AgentSession, options: Initi setModel: model => runExtensionSetModel(session, model), getThinkingLevel: () => session.thinkingLevel, setThinkingLevel: level => session.setThinkingLevel(level), + getServiceTiers: () => session.serviceTierByFamily, + setServiceTier: (family, tier) => session.setServiceTierFamily(family, tier), getSessionName: () => session.sessionManager.getSessionName(), setSessionName: async name => { await session.sessionManager.setSessionName(name, "user"); diff --git a/packages/coding-agent/src/task/executor.ts b/packages/coding-agent/src/task/executor.ts index cc898e4b1..b9aae64d8 100644 --- a/packages/coding-agent/src/task/executor.ts +++ b/packages/coding-agent/src/task/executor.ts @@ -2938,6 +2938,8 @@ export async function runSubprocess(options: ExecutorOptions): Promise runExtensionSetModel(session, model), getThinkingLevel: () => session.thinkingLevel, setThinkingLevel: level => session.setThinkingLevel(level), + getServiceTiers: () => session.serviceTierByFamily, + setServiceTier: (family, tier) => session.setServiceTierFamily(family, tier), getSessionName: () => session.sessionManager.getSessionName(), setSessionName: async name => { await session.sessionManager.setSessionName(name, "user"); diff --git a/packages/coding-agent/test/extensions-runner.test.ts b/packages/coding-agent/test/extensions-runner.test.ts index 27069ccfa..e750784f1 100644 --- a/packages/coding-agent/test/extensions-runner.test.ts +++ b/packages/coding-agent/test/extensions-runner.test.ts @@ -529,6 +529,8 @@ describe("ExtensionRunner", () => { setModel: async () => false, getThinkingLevel: () => undefined, setThinkingLevel: () => {}, + getServiceTiers: () => ({}), + setServiceTier: () => {}, getSessionName: () => undefined, setSessionName: async () => {}, }, @@ -1206,6 +1208,8 @@ describe("ExtensionRunner", () => { setModel: async () => false, getThinkingLevel: () => undefined, setThinkingLevel: () => {}, + getServiceTiers: () => ({}), + setServiceTier: () => {}, getSessionName: () => undefined, setSessionName: async () => {}, }, @@ -1232,6 +1236,84 @@ describe("ExtensionRunner", () => { }); }); + describe("service tier API", () => { + it("returns a detached snapshot, forwards valid changes, and rejects invalid family tiers", async () => { + const extCode = ` + export default function(pi) { + pi.on("session_start", () => { + const tiers = pi.getServiceTiers(); + tiers.openai = "scale"; + pi.appendEntry("service-tier-snapshot", tiers); + pi.setServiceTier("google", "flex"); + pi.setServiceTier("openai", undefined); + pi.setServiceTier("anthropic", "scale"); + }); + } + `; + const explicitExtensionPath = path.join(tempDir.path(), "service-tiers.ts"); + fs.writeFileSync(explicitExtensionPath, extCode); + const result = await loadTestExtensions([explicitExtensionPath]); + const runner = new ExtensionRunner( + result.extensions, + result.runtime, + tempDir.path(), + sessionManager, + modelRegistry, + ); + const serviceTiers = { openai: "priority" as const }; + const snapshots: unknown[] = []; + const setCalls: Array<[string, unknown]> = []; + const errors: string[] = []; + runner.onError(error => { + errors.push(error.error); + }); + runner.initialize( + { + sendMessage: () => {}, + sendUserMessage: () => {}, + appendEntry: (_customType, data) => { + snapshots.push(data); + }, + setLabel: () => {}, + getActiveTools: () => [], + getAllTools: () => [], + setActiveTools: async () => {}, + getCommands: () => [], + setModel: async () => false, + getThinkingLevel: () => undefined, + setThinkingLevel: () => {}, + getServiceTiers: () => serviceTiers, + setServiceTier: (family, tier) => { + setCalls.push([family, tier]); + }, + getSessionName: () => undefined, + setSessionName: async () => {}, + }, + { + getModel: () => undefined, + isIdle: () => true, + abort: () => {}, + hasPendingMessages: () => false, + shutdown: () => {}, + getContextUsage: () => undefined, + compact: async () => {}, + getSystemPrompt: () => [], + }, + ); + + await runner.emit({ type: "session_start" }); + + expect(serviceTiers).toEqual({ openai: "priority" }); + expect(snapshots).toEqual([{ openai: "scale" }]); + expect(setCalls).toEqual([ + ["google", "flex"], + ["openai", undefined], + ]); + expect(errors).toHaveLength(1); + expect(errors[0]).toContain('Invalid service tier "scale" for family "anthropic"'); + }); + }); + describe("session name API", () => { it("lets extensions read and set the session name after initialization", async () => { const extCode = ` @@ -1268,6 +1350,8 @@ describe("ExtensionRunner", () => { setModel: async () => false, getThinkingLevel: () => undefined, setThinkingLevel: () => {}, + getServiceTiers: () => ({}), + setServiceTier: () => {}, getSessionName: () => sessionManager.getSessionName(), setSessionName: async name => { await sessionManager.setSessionName(name); @@ -1326,6 +1410,8 @@ describe("ExtensionRunner", () => { setModel: async () => false, getThinkingLevel: () => undefined, setThinkingLevel: () => {}, + getServiceTiers: () => ({}), + setServiceTier: () => {}, getSessionName: () => undefined, setSessionName: async () => {}, }, @@ -1993,6 +2079,8 @@ describe("ExtensionRunner", () => { setModel: async () => false, getThinkingLevel: () => undefined, setThinkingLevel: () => {}, + getServiceTiers: () => ({}), + setServiceTier: () => {}, getSessionName: () => sessionManager.getSessionName(), setSessionName: async () => {}, }, diff --git a/packages/coding-agent/test/sdk-credential-disabled-bridge.test.ts b/packages/coding-agent/test/sdk-credential-disabled-bridge.test.ts index 6ec022e72..1365dbbc1 100644 --- a/packages/coding-agent/test/sdk-credential-disabled-bridge.test.ts +++ b/packages/coding-agent/test/sdk-credential-disabled-bridge.test.ts @@ -60,6 +60,8 @@ const initializeRunnerForTest = (runner: ExtensionRunner | undefined): void => { setModel: async () => false, getThinkingLevel: () => undefined, setThinkingLevel: () => {}, + getServiceTiers: () => ({}), + setServiceTier: () => {}, getSessionName: () => undefined, setSessionName: async () => {}, }, @@ -527,6 +529,8 @@ describe("createAgentSession credential_disabled subscription", () => { setModel: async () => false, getThinkingLevel: () => undefined, setThinkingLevel: () => {}, + getServiceTiers: () => ({}), + setServiceTier: () => {}, getSessionName: () => undefined, setSessionName: async () => {}, },