diff --git a/src/resources/extensions/gsd/auto-dashboard.ts b/src/resources/extensions/gsd/auto-dashboard.ts index ddedc466f..3a18fb0c7 100644 --- a/src/resources/extensions/gsd/auto-dashboard.ts +++ b/src/resources/extensions/gsd/auto-dashboard.ts @@ -24,6 +24,7 @@ import { GLYPH, INDENT } from "../shared/mod.js"; import { computeProgressScore } from "./progress-score.js"; import { getActiveWorktreeName } from "./worktree-command.js"; import { loadEffectiveGSDPreferences, getGlobalGSDPreferencesPath } from "./preferences.js"; +import { resolveServiceTierIcon, getEffectiveServiceTier } from "./service-tier.js"; // ─── UAT Slice Extraction ───────────────────────────────────────────────────── @@ -460,6 +461,9 @@ export function updateProgressWidget( // Pre-fetch last commit for display refreshLastCommit(accessors.getBasePath()); + // Cache the effective service tier at widget creation time (reads preferences) + const effectiveServiceTier = getEffectiveServiceTier(); + ctx.ui.setWidget("gsd-progress", (tui, theme) => { let pulseBright = true; let cachedLines: string[] | undefined; @@ -572,9 +576,10 @@ export function updateProgressWidget( // Model display — shown in context section, not stats const modelId = cmdCtx?.model?.id ?? ""; const modelProvider = cmdCtx?.model?.provider ?? ""; - const modelDisplay = modelProvider && modelId + const tierIcon = resolveServiceTierIcon(effectiveServiceTier, modelId); + const modelDisplay = (modelProvider && modelId ? `${modelProvider}/${modelId}` - : modelId; + : modelId) + (tierIcon ? ` ${tierIcon}` : ""); // ── Mode: off — return empty ────────────────────────────────── if (widgetMode === "off") { diff --git a/src/resources/extensions/gsd/bootstrap/register-hooks.ts b/src/resources/extensions/gsd/bootstrap/register-hooks.ts index 2a381488f..1ff2452f9 100644 --- a/src/resources/extensions/gsd/bootstrap/register-hooks.ts +++ b/src/resources/extensions/gsd/bootstrap/register-hooks.ts @@ -191,5 +191,18 @@ export function registerHooks(pi: ExtensionAPI): void { pi.on("tool_execution_end", async (event) => { markToolEnd(event.toolCallId); }); + + pi.on("before_provider_request", async (event) => { + if (!isAutoActive()) return; + const modelId = event.model?.id; + if (!modelId) return; + const { getEffectiveServiceTier, supportsServiceTier } = await import("../service-tier.js"); + const tier = getEffectiveServiceTier(); + if (!tier || !supportsServiceTier(modelId)) return; + const payload = event.payload as Record | null; + if (!payload || typeof payload !== "object") return; + payload.service_tier = tier; + return payload; + }); } diff --git a/src/resources/extensions/gsd/commands/catalog.ts b/src/resources/extensions/gsd/commands/catalog.ts index 74c25afcb..a9cbe2f3d 100644 --- a/src/resources/extensions/gsd/commands/catalog.ts +++ b/src/resources/extensions/gsd/commands/catalog.ts @@ -14,7 +14,7 @@ export interface GsdCommandDefinition { type CompletionMap = Record; export const GSD_COMMAND_DESCRIPTION = - "GSD — Get Shit Done: /gsd help|start|templates|next|auto|stop|pause|status|widget|visualize|queue|quick|discuss|capture|triage|dispatch|history|undo|rate|skip|export|cleanup|mode|prefs|config|keys|hooks|run-hook|skill-health|doctor|logs|forensics|changelog|migrate|remote|steer|knowledge|new-milestone|parallel|cmux|park|unpark|init|setup|inspect|extensions|update"; + "GSD — Get Shit Done: /gsd help|start|templates|next|auto|stop|pause|status|widget|visualize|queue|quick|discuss|capture|triage|dispatch|history|undo|rate|skip|export|cleanup|mode|prefs|config|keys|hooks|run-hook|skill-health|doctor|logs|forensics|changelog|migrate|remote|steer|knowledge|new-milestone|parallel|cmux|park|unpark|init|setup|inspect|extensions|update|fast"; export const TOP_LEVEL_SUBCOMMANDS: readonly GsdCommandDefinition[] = [ { cmd: "help", desc: "Categorized command reference with descriptions" }, @@ -64,6 +64,7 @@ export const TOP_LEVEL_SUBCOMMANDS: readonly GsdCommandDefinition[] = [ { cmd: "start", desc: "Start a workflow template (bugfix, spike, feature, etc.)" }, { cmd: "templates", desc: "List available workflow templates" }, { cmd: "extensions", desc: "Manage extensions (list, enable, disable, info)" }, + { cmd: "fast", desc: "Toggle OpenAI service tier (on/off/flex/status)" }, ]; const NESTED_COMPLETIONS: CompletionMap = { @@ -176,6 +177,12 @@ const NESTED_COMPLETIONS: CompletionMap = { { cmd: "disable", desc: "Disable an extension" }, { cmd: "info", desc: "Show extension details" }, ], + fast: [ + { cmd: "on", desc: "Priority tier (2x cost, faster)" }, + { cmd: "off", desc: "Disable service tier" }, + { cmd: "flex", desc: "Flex tier (0.5x cost, slower)" }, + { cmd: "status", desc: "Show current service tier setting" }, + ], doctor: [ { cmd: "fix", desc: "Auto-fix detected issues" }, { cmd: "heal", desc: "AI-driven deep healing" }, diff --git a/src/resources/extensions/gsd/commands/handlers/core.ts b/src/resources/extensions/gsd/commands/handlers/core.ts index 3f759daf9..3028f72c5 100644 --- a/src/resources/extensions/gsd/commands/handlers/core.ts +++ b/src/resources/extensions/gsd/commands/handlers/core.ts @@ -52,6 +52,7 @@ export function showHelp(ctx: ExtensionCommandContext): void { " /gsd keys API key manager [list|add|remove|test|rotate|doctor]", " /gsd hooks Show post-unit hook configuration", " /gsd extensions Manage extensions [list|enable|disable|info]", + " /gsd fast Toggle OpenAI service tier [on|off|flex|status]", "", "MAINTENANCE", " /gsd doctor Diagnose and repair .gsd/ state [audit|fix|heal] [scope]", diff --git a/src/resources/extensions/gsd/commands/handlers/ops.ts b/src/resources/extensions/gsd/commands/handlers/ops.ts index 5108bb0ad..763c434f3 100644 --- a/src/resources/extensions/gsd/commands/handlers/ops.ts +++ b/src/resources/extensions/gsd/commands/handlers/ops.ts @@ -172,6 +172,11 @@ Examples: await handleUpdate(ctx); return true; } + if (trimmed === "fast" || trimmed.startsWith("fast ")) { + const { handleFast } = await import("../../service-tier.js"); + await handleFast(trimmed.replace(/^fast\s*/, "").trim(), ctx); + return true; + } if (trimmed === "extensions" || trimmed.startsWith("extensions ")) { const { handleExtensions } = await import("../../commands-extensions.js"); await handleExtensions(trimmed.replace(/^extensions\s*/, "").trim(), ctx); diff --git a/src/resources/extensions/gsd/preferences-types.ts b/src/resources/extensions/gsd/preferences-types.ts index d1c81f250..36e6f83f5 100644 --- a/src/resources/extensions/gsd/preferences-types.ts +++ b/src/resources/extensions/gsd/preferences-types.ts @@ -88,6 +88,7 @@ export const KNOWN_PREFERENCE_KEYS = new Set([ "widget_mode", "reactive_execution", "github", + "service_tier", ]); /** Canonical list of all dispatch unit types. */ @@ -220,6 +221,8 @@ export interface GSDPreferences { reactive_execution?: ReactiveExecutionConfig; /** GitHub sync configuration. Opt-in: syncs GSD events to GitHub Issues, Milestones, and PRs. */ github?: GitHubSyncConfig; + /** OpenAI service tier preference. "priority" = 2x cost, faster. "flex" = 0.5x cost, slower. Only affects gpt-5.4 models. */ + service_tier?: "priority" | "flex"; } export interface LoadedGSDPreferences { diff --git a/src/resources/extensions/gsd/preferences.ts b/src/resources/extensions/gsd/preferences.ts index 15f5c0b3c..e369525cc 100644 --- a/src/resources/extensions/gsd/preferences.ts +++ b/src/resources/extensions/gsd/preferences.ts @@ -285,6 +285,7 @@ function mergePreferences(base: GSDPreferences, override: GSDPreferences): GSDPr github: (base.github || override.github) ? { ...(base.github ?? {}), ...(override.github ?? {}) } as import("../github-sync/types.js").GitHubSyncConfig : undefined, + service_tier: override.service_tier ?? base.service_tier, }; } diff --git a/src/resources/extensions/gsd/service-tier.ts b/src/resources/extensions/gsd/service-tier.ts new file mode 100644 index 000000000..7e2f4613a --- /dev/null +++ b/src/resources/extensions/gsd/service-tier.ts @@ -0,0 +1,171 @@ +/** + * Service Tier — gating, status formatting, icon resolution, and + * the /gsd fast command handler. + * + * Service tiers (priority/flex) are an OpenAI feature that only applies + * to gpt-5.4 variants. This module centralizes the model-gating logic + * so that icons, preferences, and the before_provider_request hook all + * use a single source of truth. + */ + +import type { ExtensionCommandContext } from "@gsd/pi-coding-agent"; + +import { existsSync, readFileSync } from "node:fs"; +import { saveFile } from "./files.js"; +import { + getGlobalGSDPreferencesPath, + loadEffectiveGSDPreferences, + loadGlobalGSDPreferences, +} from "./preferences.js"; +import { ensurePreferencesFile, serializePreferencesToFrontmatter } from "./commands-prefs-wizard.js"; + +// ─── Types ─────────────────────────────────────────────────────────────────── + +export type ServiceTierSetting = "priority" | "flex" | undefined; + +// ─── Gating ────────────────────────────────────────────────────────────────── + +/** + * Returns true when the given model ID supports OpenAI service tiers. + * Currently only gpt-5.4 variants qualify. + */ +export function supportsServiceTier(modelId: string): boolean { + if (!modelId) return false; + // Strip provider prefix if present (e.g. "openai/gpt-5.4" → "gpt-5.4") + const bare = modelId.includes("/") ? modelId.split("/").pop()! : modelId; + return bare.startsWith("gpt-5.4"); +} + +// ─── Status Formatting ─────────────────────────────────────────────────────── + +/** + * Human-readable description of the current service tier setting. + */ +export function formatServiceTierStatus(tier: ServiceTierSetting): string { + if (!tier) { + return [ + "Service tier: disabled", + "", + "Usage:", + " /gsd fast on Set to priority (2x cost, faster)", + " /gsd fast flex Set to flex (0.5x cost, slower)", + " /gsd fast off Disable service tier", + "", + "Only affects gpt-5.4 models.", + ].join("\n"); + } + + const label = tier === "priority" ? "priority (2x cost, faster)" : "flex (0.5x cost, slower)"; + return [ + `Service tier: ${label}`, + "", + "Usage:", + " /gsd fast on Set to priority (2x cost, faster)", + " /gsd fast flex Set to flex (0.5x cost, slower)", + " /gsd fast off Disable service tier", + "", + "Only affects gpt-5.4 models.", + ].join("\n"); +} + +// ─── Icon Resolution ───────────────────────────────────────────────────────── + +/** + * Returns the appropriate icon for the active service tier and model. + * Returns empty string when the tier is inactive or the model doesn't + * support service tiers. + */ +export function resolveServiceTierIcon(tier: ServiceTierSetting, modelId: string): string { + if (!tier || !supportsServiceTier(modelId)) return ""; + return tier === "priority" ? "⚡" : "💰"; +} + +// ─── Preference Read ───────────────────────────────────────────────────────── + +/** + * Read the effective service_tier setting from preferences. + */ +export function getEffectiveServiceTier(): ServiceTierSetting { + const prefs = loadEffectiveGSDPreferences()?.preferences; + const raw = prefs?.service_tier; + if (raw === "priority" || raw === "flex") return raw; + return undefined; +} + +// ─── Preference Write ──────────────────────────────────────────────────────── + +function extractBodyAfterFrontmatter(content: string): string | null { + const start = content.startsWith("---\n") ? 4 : content.startsWith("---\r\n") ? 5 : -1; + if (start === -1) return null; + const closingIdx = content.indexOf("\n---", start); + if (closingIdx === -1) return null; + const after = content.slice(closingIdx + 4); + return after.trim() ? after : null; +} + +async function writeGlobalServiceTier( + ctx: ExtensionCommandContext, + tier: ServiceTierSetting, +): Promise { + const path = getGlobalGSDPreferencesPath(); + await ensurePreferencesFile(path, ctx, "global"); + + const existing = loadGlobalGSDPreferences(); + const prefs: Record = existing?.preferences ? { ...existing.preferences } : {}; + prefs.version = prefs.version || 1; + + if (tier) { + prefs.service_tier = tier; + } else { + delete prefs.service_tier; + } + + const frontmatter = serializePreferencesToFrontmatter(prefs); + let body = "\n# GSD Skill Preferences\n\nSee `~/.gsd/agent/extensions/gsd/docs/preferences-reference.md` for full field documentation and examples.\n"; + if (existsSync(path)) { + const preserved = extractBodyAfterFrontmatter(readFileSync(path, "utf-8")); + if (preserved) body = preserved; + } + + await saveFile(path, `---\n${frontmatter}---${body}`); + await ctx.waitForIdle(); + await ctx.reload(); +} + +// ─── Command Handler ───────────────────────────────────────────────────────── + +/** + * Handle `/gsd fast [on|off|flex|status]`. + */ +export async function handleFast(args: string, ctx: ExtensionCommandContext): Promise { + const trimmed = args.trim().toLowerCase(); + + if (!trimmed || trimmed === "status") { + const tier = getEffectiveServiceTier(); + ctx.ui.notify(formatServiceTierStatus(tier), "info"); + return; + } + + if (trimmed === "on") { + await writeGlobalServiceTier(ctx, "priority"); + ctx.ui.notify("Service tier set to priority (2x cost, faster responses). Only affects gpt-5.4 models.", "info"); + return; + } + + if (trimmed === "off") { + await writeGlobalServiceTier(ctx, undefined); + ctx.ui.notify("Service tier disabled.", "info"); + return; + } + + if (trimmed === "flex") { + await writeGlobalServiceTier(ctx, "flex"); + ctx.ui.notify("Service tier set to flex (0.5x cost, slower responses). Only affects gpt-5.4 models.", "info"); + return; + } + + ctx.ui.notify( + "Usage: /gsd fast [on|off|flex|status]\n\n on Priority tier (2x cost, faster)\n off Disable service tier\n flex Flex tier (0.5x cost, slower)\n status Show current setting", + "warning", + ); +} diff --git a/src/resources/extensions/gsd/tests/service-tier.test.ts b/src/resources/extensions/gsd/tests/service-tier.test.ts new file mode 100644 index 000000000..ff6d0b684 --- /dev/null +++ b/src/resources/extensions/gsd/tests/service-tier.test.ts @@ -0,0 +1,98 @@ +import test, { describe } from "node:test"; +import assert from "node:assert/strict"; + +import { + supportsServiceTier, + formatServiceTierStatus, + resolveServiceTierIcon, + type ServiceTierSetting, +} from "../service-tier.ts"; + +// ─── supportsServiceTier ───────────────────────────────────────────────────── + +describe("supportsServiceTier", () => { + test("returns true for gpt-5.4", () => { + assert.equal(supportsServiceTier("gpt-5.4"), true); + }); + + test("returns true for gpt-5.4-pro", () => { + assert.equal(supportsServiceTier("gpt-5.4-pro"), true); + }); + + test("returns true for gpt-5.4-mini", () => { + assert.equal(supportsServiceTier("gpt-5.4-mini"), true); + }); + + test("returns true for openai/gpt-5.4 (provider-prefixed)", () => { + assert.equal(supportsServiceTier("openai/gpt-5.4"), true); + }); + + test("returns false for claude-opus-4-6", () => { + assert.equal(supportsServiceTier("claude-opus-4-6"), false); + }); + + test("returns false for gemini-2.5-pro", () => { + assert.equal(supportsServiceTier("gemini-2.5-pro"), false); + }); + + test("returns false for gpt-4o", () => { + assert.equal(supportsServiceTier("gpt-4o"), false); + }); + + test("returns false for empty string", () => { + assert.equal(supportsServiceTier(""), false); + }); +}); + +// ─── formatServiceTierStatus ───────────────────────────────────────────────── + +describe("formatServiceTierStatus", () => { + test("shows disabled when service_tier is undefined", () => { + const output = formatServiceTierStatus(undefined); + assert.ok(output.includes("disabled"), `Expected 'disabled' in: ${output}`); + }); + + test("shows priority when set to priority", () => { + const output = formatServiceTierStatus("priority"); + assert.ok(output.includes("priority"), `Expected 'priority' in: ${output}`); + }); + + test("shows flex when set to flex", () => { + const output = formatServiceTierStatus("flex"); + assert.ok(output.includes("flex"), `Expected 'flex' in: ${output}`); + }); +}); + +// ─── resolveServiceTierIcon ────────────────────────────────────────────────── + +describe("resolveServiceTierIcon", () => { + test("returns lightning bolt for priority tier on supported model", () => { + const icon = resolveServiceTierIcon("priority", "gpt-5.4"); + assert.equal(icon, "⚡"); + }); + + test("returns money icon for flex tier on supported model", () => { + const icon = resolveServiceTierIcon("flex", "gpt-5.4"); + assert.equal(icon, "💰"); + }); + + test("returns empty string when tier is set but model does not support it", () => { + const icon = resolveServiceTierIcon("priority", "claude-opus-4-6"); + assert.equal(icon, ""); + }); + + test("returns empty string when tier is undefined", () => { + const icon = resolveServiceTierIcon(undefined, "gpt-5.4"); + assert.equal(icon, ""); + }); + + test("returns empty string when both tier and model are unsupported", () => { + const icon = resolveServiceTierIcon(undefined, "claude-opus-4-6"); + assert.equal(icon, ""); + }); + + test("returns empty string when model is empty", () => { + const icon = resolveServiceTierIcon("priority", ""); + assert.equal(icon, ""); + }); +});