Add `/gsd fast [on|off|flex|status]` command for toggling OpenAI service tiers, with `supportsServiceTier()` gating so the status bar icon only appears on models that actually support service tiers (gpt-5.4 variants). Fixes #1848 Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
0dd7176af6
commit
7140ee0f53
9 changed files with 307 additions and 3 deletions
|
|
@ -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") {
|
||||
|
|
|
|||
|
|
@ -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<string, unknown> | null;
|
||||
if (!payload || typeof payload !== "object") return;
|
||||
payload.service_tier = tier;
|
||||
return payload;
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ export interface GsdCommandDefinition {
|
|||
type CompletionMap = Record<string, readonly GsdCommandDefinition[]>;
|
||||
|
||||
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" },
|
||||
|
|
|
|||
|
|
@ -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]",
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -88,6 +88,7 @@ export const KNOWN_PREFERENCE_KEYS = new Set<string>([
|
|||
"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 {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
171
src/resources/extensions/gsd/service-tier.ts
Normal file
171
src/resources/extensions/gsd/service-tier.ts
Normal file
|
|
@ -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<void> {
|
||||
const path = getGlobalGSDPreferencesPath();
|
||||
await ensurePreferencesFile(path, ctx, "global");
|
||||
|
||||
const existing = loadGlobalGSDPreferences();
|
||||
const prefs: Record<string, unknown> = 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<void> {
|
||||
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",
|
||||
);
|
||||
}
|
||||
98
src/resources/extensions/gsd/tests/service-tier.test.ts
Normal file
98
src/resources/extensions/gsd/tests/service-tier.test.ts
Normal file
|
|
@ -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, "");
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue