feat(extensions): expose session service tiers

This commit is contained in:
Alex TYRODE
2026-07-23 20:58:18 +00:00
parent 126765c020
commit e0928070c2
12 changed files with 191 additions and 2 deletions
+3
View File
@@ -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:
+1
View File
@@ -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
@@ -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
@@ -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<ServiceTierByFamily> {
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();
}
@@ -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;
@@ -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<E, R = undefined> = (event: E, ctx: ExtensionContext) => Promise<R | void> | R | void;
/** Service tiers accepted by each provider family. */
export type ExtensionServiceTier<Family extends ServiceTierFamily> = 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<ServiceTierByFamily>;
/**
* Set one provider family's service tier for subsequent requests, or clear
* its session override with `undefined`.
*/
setServiceTier<Family extends ServiceTierFamily>(
family: Family,
tier: ExtensionServiceTier<Family> | 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<string, boolean | string>;
@@ -1429,6 +1455,8 @@ export interface ExtensionActions {
setModel: SetModelHandler;
getThinkingLevel: GetThinkingLevelHandler;
setThinkingLevel: SetThinkingLevelHandler;
getServiceTiers: GetServiceTiersHandler;
setServiceTier: SetServiceTierHandler;
getSessionName: () => string | undefined;
setSessionName: (name: string) => Promise<void>;
}
@@ -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");
@@ -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),
@@ -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");
@@ -2938,6 +2938,8 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
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");
@@ -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 () => {},
},
@@ -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 () => {},
},