/** * pi-glm-tweaks — Pi-native tweaks for Z.AI's GLM-5.2. * * Restricts the Pi thinking-level UI to the three modes GLM-5.2 actually * supports (off, high, max), wires the native `thinkingFormat: "zai"` wire * translation, auto-clamps hidden levels, and applies token-efficiency * hygiene (per-turn system-prompt nudge, wire-level * clear_thinking and skip-short-thinking). * * Wire map (see https://docs.z.ai/guides/capabilities/thinking and * providers/openai-completions.js in pi-ai): * * Pi level | thinking.type | reasoning_effort * ----------|---------------|------------------ * off | "disabled" | (omitted) * high | "enabled" | "high" * xhigh | "enabled" | "max" * * Hidden levels (minimal, low, medium) are Pi-side concepts that don't map * cleanly: low/medium get server-side-mapped to "high", minimal is a no-op * for Pi's reasoning transport. Showing them invites accidental footguns. * * Behavior: * - On session_start, re-register the `zai` provider with GLM-5.2 redefined * against the OpenAI-compat endpoint and the tight thinkingLevelMap. * registerProvider takes effect immediately after bindCore (no /reload). * - On model_select to zai/glm-5.2, clamp a stale hidden level to "high" * and notify. Set the footer status hint. * - On model_select to any other model, clear the footer status. * - On every user turn, inject a soft system-prompt budget fragment * (`glm-budget-nudge`, default OFF — rewrites the system prompt every * turn, which drifts the cached prefix). * - Per LLM call, count cumulative reasoning_content; if over a * threshold, inject a one-shot user-side hint to push the model back * toward tool calls (`glm-budget-nudge`). * - On every outgoing request, force `clear_thinking: true` * (`glm-clear-thinking`, default OFF). The coding endpoint ships Preserved * Thinking ON by default precisely to maximize cache hit rates (see * z.ai Thinking Mode docs); forcing it off re-bills the full prefix * every turn. * - On short user prompts (<80 chars), force `thinking.type: "disabled"` * to save tokens on trivial turns (`glm-skip-short-thinking`, default * OFF — toggling request shape turn-to-turn reduces cache hits). * * Auth is untouched. The provider's existing key (ZAI_API_KEY env, /login, * or models.json apiKey) continues to resolve against the new baseUrl. */ import { getSettingsListTheme, type ExtensionAPI } from "@earendil-works/pi-coding-agent"; import { Container, SettingsList, Text, type SettingItem } from "@earendil-works/pi-tui"; import { loadFlagSettings, saveFlagSetting } from "../lib/flag-settings"; const PROVIDER = "zai"; const MODEL_ID = "glm-5.2"; const ZAI_CODING_BASE_URL = "https://api.z.ai/api/coding/paas/v4"; // Pi thinking-level keys we hide for GLM-5.2. Listed explicitly so the map // stays grep-friendly; any level not present (notably `off`) is supported // with the provider's default mapping (here: thinking.type="disabled"). const HIDDEN_LEVELS = new Set(["minimal", "low", "medium"]); // Token-efficiency tuning constant. Hardcoded for v1 — exposed as a flag // would be over-engineering for a single-model extension. const SHORT_PROMPT_THRESHOLD = 80; // Token-efficiency flags. Single source of truth — drives registerFlag, // the /glm-tweaks status display, autocomplete, and the toggle subcommand. // glm-budget-nudge defaults ON (cache-safe: it appends a fixed fragment to // the system prompt, so the prefix stays byte-identical turn to turn and the // z.ai server cache is reused). The other two default OFF because they // undermine the coding endpoint's Preserved Thinking caching // (see docs.z.ai/guides/capabilities/thinking-mode). Users who want them can // opt in via /glm-tweaks. const FLAGS = [ { name: "glm-budget-nudge", label: "Budget nudge", default: true, description: "Append a constant thinking-budget fragment to the system prompt on every zai/glm-5.2 turn, steering the model toward committing to a tool call before overthinking. Cache-safe: the fragment is a fixed string, so the appended system prompt stays byte-identical turn to turn and the cached prefix is reused. (The earlier mid-loop ratchet that injected a timestamped user message was removed because it broke the cache.)", }, { name: "glm-clear-thinking", label: "Clear thinking", default: false, description: "Force thinking.clear_thinking=true on every request, opting out of z.ai Preserved Thinking. Cache: Preserved Thinking (clear_thinking=false) is the coding endpoint's default because it keeps reasoning_content byte-identical across turns, which is exactly what the server caches; disabling it strips reasoning each turn so the next turn's prefix no longer matches the cache → full re-bill (e.g. 'Cache miss: 140k tokens re-billed').", }, { name: "glm-skip-short-thinking", label: "Skip short thinking", default: false, description: "For user prompts under 80 chars, force thinking.type=disabled for that turn. Cache: toggles thinking on/off across turns based on prompt length, which changes the reasoning_content sequence z.ai caches; follow-up turns on the same session re-bill instead of hitting the cached prefix.", }, ] as const; // Soft system-prompt fragment appended to every zai/glm-5.2 turn when // the budget-nudge flag is on. No "I'm overthinking" ack string — that's // unenforceable (model may or may not emit it, may emit it in Chinese, // and we'd have to detect it). const BUDGET_FRAGMENT = ` You are operating under a per-turn thinking budget. Behave accordingly: - Cap each thinking block at ~500 tokens. Don't ruminate; commit to a tool call or response. - Take a tool call every 200-300 thinking tokens. Don't sit and speculate without acting. - Prefer a concrete tool call over further internal deliberation. `; // Redefined glm-5.2 model entry. `cost` mirrors the built-in (Z.AI does // not publish per-token rates; zeros is conservative). thinkingLevelMap // doubles as UI-hide (`null`) and wire-level safety net: Pi's zai branch // in openai-completions.js reads this map for reasoning_effort, and a // null entry produces no reasoning_effort field on the wire. baseUrl // is per-model (not provider-level) so we don't override any custom // baseUrl the user may have set on other `zai/*` models. const GLM52_MODEL = { id: MODEL_ID, name: "GLM-5.2", api: "openai-completions", baseUrl: ZAI_CODING_BASE_URL, reasoning: true, input: ["text"] as ("text" | "image")[], contextWindow: 1_000_000, maxTokens: 131_072, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, thinkingLevelMap: { minimal: null, low: null, medium: null, high: "high", xhigh: "max", }, compat: { supportsDeveloperRole: false, supportsReasoningEffort: true, thinkingFormat: "zai" as const, zaiToolStream: true, }, }; function isZaiGlm52(model: { provider: string; id: string } | undefined | null): boolean { return !!model && model.provider === PROVIDER && model.id === MODEL_ID; } // Build the /glm-tweaks status panel. Read-only snapshot of the active // model, current thinking level, and the on/off state of every flag. function renderStatus( pi: ExtensionAPI, model: { provider: string; id: string } | undefined, ): string { const active = isZaiGlm52(model); const level = pi.getThinkingLevel(); const flagLines = FLAGS.map((f) => ` ${pi.getFlag(f.name) === true ? "[x]" : "[ ]"} ${f.name}`); return [ `GLM-5.2 tweaks — ${active ? "ACTIVE (zai/glm-5.2 selected)" : "inactive (select zai/glm-5.2 to engage)"}`, `thinking: ${active ? `current=${level}, wire=off|high|max` : "n/a"}`, "", "flags:", ...flagLines, "", "toggle: /glm-tweaks toggle (shorthand: /glm-tweaks )", ].join("\n"); } export default function (pi: ExtensionAPI) { // Register Pi-idiomatic flags at factory load time, NOT inside // session_start. registerFlag is static setup; calling it per session // would clobber user preferences on every /new or /reload. Defaults are // seeded from the persisted map in /pi-glm-tweaks.json so toggles // survive pi restarts; missing/unknown flags fall back to the flag's own // `default` (cache-safe: all three token-efficiency flags default off). // // Persisted-wins is the load-bearing invariant for the v1.2.0 default // flip: a 1.1.2 user who explicitly toggled a flag to `true` (even though // that matched the old default, so the toggle looked like a no-op at the // time) has a real `{ "": true }` entry on disk today, and // `f.name in persisted` picks it up post-upgrade so their explicit // choice is preserved. The one-shot toggle handler persists every flip // unconditionally (no no-op skip), which is what makes this hold. const persisted = loadFlagSettings(); for (const f of FLAGS) { pi.registerFlag(f.name, { description: f.description, type: "boolean", default: f.name in persisted ? persisted[f.name] : f.default, }); } // /glm-tweaks — status display by default; `toggle ` (or bare // ``) flips a boolean. ExtensionAPI exposes no live setFlag, so a // toggle is written to /pi-glm-tweaks.json (lib/flag-settings.ts) // and then reloads the session so registerFlag re-seeds the in-memory // default from disk. ctx is stale after reload() — we notify first, // reload last, and return immediately. pi.registerCommand("glm-tweaks", { description: "GLM-5.2 tweaks: show status, or toggle a flag. Usage: /glm-tweaks [toggle ]", getArgumentCompletions: (prefix: string) => { // Preserve trailing space: `/glm-tweaks toggle ` (with space) means // the `toggle` token is complete and we should now suggest flags. // Trimming would collapse it to "toggle" and re-suggest the word. const trailingSpace = /\s$/.test(prefix); const tokens = prefix.trim().split(/\s+/).filter(Boolean); const flagNames = FLAGS.map((f) => f.name); const root = ["toggle", ...flagNames]; // Suggest flag names once `toggle` is complete (either as the only // token with a trailing space, or with a partial flag typed). const toggleComplete = (tokens.length === 1 && tokens[0] === "toggle") || (tokens.length >= 2 && tokens[0] === "toggle"); if (toggleComplete) { const partial = tokens.length >= 2 ? tokens[tokens.length - 1] : ""; const hits = flagNames.filter((n) => n.startsWith(partial)); return hits.length ? hits.map((v) => ({ value: v, label: v })) : null; } if (tokens.length <= 1 && !trailingSpace) { const hits = root.filter((o) => o.startsWith(tokens[0] ?? "")); return hits.length ? hits.map((v) => ({ value: v, label: v })) : null; } return null; }, handler: async (args, ctx) => { const trimmed = args.trim(); // Toggle mode: `/glm-tweaks toggle ` or `/glm-tweaks `. // Direct one-shot flip — persists to the settings file then reloads. // Bare `/glm-tweaks toggle` (no flag) falls through to the menu. if (trimmed !== "" && trimmed !== "status" && trimmed !== "toggle") { const tokens = trimmed.split(/\s+/).filter(Boolean); const flagName = tokens[0] === "toggle" ? tokens[1] : tokens[0]; const meta = FLAGS.find((f) => f.name === flagName); if (!meta) { ctx.ui.notify( `Unknown flag "${flagName}". Valid: ${FLAGS.map((f) => f.name).join(", ")}`, "warning", ); return; } const current = pi.getFlag(meta.name) === true; const next = !current; if (!saveFlagSetting(meta.name, next)) { ctx.ui.notify(`Failed to persist ${meta.name} to settings file.`, "error"); return; } ctx.ui.notify(`${meta.name}: ${current} → ${next}. Reloading...`, "info"); await ctx.reload(); return; } // Status/menu mode. In TUI, open an interactive SettingsList // (same component /settings uses) so the user can flip several // flags in one visit; changes persist to the settings file and a // single reload fires on close. Outside TUI (RPC/headless), fall // back to the read-only status panel — custom components are // terminal-only. if (ctx.mode !== "tui") { ctx.ui.notify(renderStatus(pi, ctx.model), "info"); return; } const active = isZaiGlm52(ctx.model); const pending = new Map(); const items: SettingItem[] = FLAGS.map((f) => ({ id: f.name, label: f.label, description: f.description, currentValue: pi.getFlag(f.name) === true ? "on" : "off", values: ["on", "off"], })); await ctx.ui.custom((tui, theme, _kb, done) => { const container = new Container(); const header = active ? "GLM-5.2 tweaks — zai/glm-5.2 active" : "GLM-5.2 tweaks — inactive (select zai/glm-5.2 to engage)"; container.addChild(new Text(theme.fg("accent", theme.bold(header)), 1, 1)); const settingsList = new SettingsList( items, Math.min(items.length + 2, 15), getSettingsListTheme(), (id, newValue) => { // Stage the change; persist + reload on close, not here, // so the user can flip several flags per visit. pending.set(id, newValue === "on"); }, () => done(undefined), ); container.addChild(settingsList); return { render: (w: number) => container.render(w), invalidate: () => container.invalidate(), handleInput: (data: string) => { settingsList.handleInput?.(data); tui.requestRender(); }, }; }); // Dialog closed. ctx is still valid here (reload is the only // staleness trigger, and we haven't called it yet). Drop net-zero // flips (a flag toggled on then off stages but changes nothing), // then persist genuine deltas and reload once if any moved. const deltas: Array<[string, boolean]> = []; for (const [name, val] of pending) { const currentlyOn = pi.getFlag(name) === true; if (currentlyOn === val) continue; // net-zero: toggled back to current deltas.push([name, val]); } if (deltas.length === 0) return; const failures: string[] = []; for (const [name, val] of deltas) { if (!saveFlagSetting(name, val)) failures.push(name); } if (failures.length > 0) { ctx.ui.notify(`Failed to persist: ${failures.join(", ")}`, "error"); return; } ctx.ui.notify(`Applied ${deltas.length} change(s). Reloading...`, "info"); await ctx.reload(); }, }); // Per-loop mutable state. Node.js runs the extension hooks single- // threaded, so a closure-scoped object is safe and avoids re-reading // flags + recomputing in every hook. Reset on every before_agent_start. const loop: { shortPrompt: boolean; } = { shortPrompt: false }; pi.on("session_start", async (_event, ctx) => { // Build the full `zai` provider model list, patching only glm-5.2. // registerProvider replaces ALL models for the provider when models // are provided, so a single-entry list would silently drop // glm-4.7, glm-5-turbo, glm-5.1, and any user-added zai entries. const existing = ctx.modelRegistry.getAll().filter((m) => m.provider === PROVIDER); if (existing.length === 0) return; if (!existing.some((m) => m.id === MODEL_ID)) return; // registerProvider requires apiKey (or oauth) when defining models, // even for a provider that already has auth resolved. Pull the // resolved key from the existing provider so we keep working // whether the user used ZAI_API_KEY env, /login, or models.json // apiKey. const apiKey = await ctx.modelRegistry.getApiKeyForProvider(PROVIDER); if (!apiKey) { ctx.ui.notify( "pi-glm-tweaks: ZAI auth not configured. Run `/login` or set ZAI_API_KEY to enable GLM-5.2 thinking tweaks.", "warning", ); return; } // Per-model spread preserves every original field (api, baseUrl, // headers, compat extras) for non-target models. Only glm-5.2 gets // the new thinkingLevelMap, baseUrl, and OpenAI-compat compat block. // baseUrl is set at BOTH provider level (required by validation; // satisfies the model-registry check) and per-model in GLM52_MODEL // (per-model takes precedence at request time, so any custom // baseUrl the user has on other `zai/*` models is preserved by // the spread). const models = existing.map((m) => (isZaiGlm52(m) ? GLM52_MODEL : { ...m })); pi.registerProvider(PROVIDER, { baseUrl: ZAI_CODING_BASE_URL, apiKey, models, }); }); pi.on("before_agent_start", (event, ctx) => { // Reset per-loop state at the start of each user turn. The other // hooks read this to drive their per-turn behavior. loop.shortPrompt = event.prompt.length < SHORT_PROMPT_THRESHOLD; if (!isZaiGlm52(ctx.model)) return {}; if (pi.getFlag("glm-budget-nudge") !== true) return {}; // Return the assembled prompt with our fragment appended. We must // concat (not replace) — Pi's before_agent_start chaining means // our systemPrompt replaces the upstream value, and other // extensions downstream only see what we return. return { systemPrompt: (event.systemPrompt ?? "") + BUDGET_FRAGMENT }; }); pi.on("before_provider_request", (event, ctx) => { if (!isZaiGlm52(ctx.model)) return; if (!event.payload || typeof event.payload !== "object") return; const obj = event.payload as Record; const current = obj.thinking; const thinking = current && typeof current === "object" && !Array.isArray(current) ? { ...(current as Record) } : ({} as Record); let mutated = false; // Opt out of z.ai Preserved Thinking. The coding endpoint ships // clear_thinking=false ON BY DEFAULT because preserving reasoning // across turns is what makes the prefix cacheable (see z.ai Thinking // Mode docs). Flipping this on re-bills the full prefix every turn — // hence default OFF and opt-in only. if (pi.getFlag("glm-clear-thinking") === true) { thinking.clear_thinking = true; mutated = true; } // Short-prompt thinking-skip: trivial turns ("what time is it") // don't need deep thinking. Force the kill switch and let Pi's // zai branch drop the thinking.type="disabled" through. // // Intentionally applies to every LLM call in the loop, not just the // first: loop.shortPrompt is computed once from the initial prompt // and held constant (see before_agent_start). A short prompt that // spawns tool calls stays thinking-free for the whole turn. if (pi.getFlag("glm-skip-short-thinking") === true && loop.shortPrompt) { thinking.type = "disabled"; mutated = true; } if (mutated) { obj.thinking = thinking; } return obj; }); pi.on("model_select", (event, ctx) => { if (!isZaiGlm52(event.model)) { ctx.ui.setStatus("glm-thinking", undefined); return; } // Auto-clamp if Pi's current level is one we hid for GLM-5.2. // setThinkingLevel is a no-op if already at the requested level. const current = pi.getThinkingLevel(); if (HIDDEN_LEVELS.has(current)) { pi.setThinkingLevel("high"); ctx.ui.notify( `GLM-5.2 thinking: "${current}" not supported. Switched to high (off | high | max).`, "info", ); } ctx.ui.setStatus("glm-thinking", "thinking: off | high | max"); }); }