/** * minimax-m3-clean — MiniMax-M3 on the OpenAI-compatible endpoint with * clean streaming output. * * pi 0.79.1 routes the built-in `minimax / MiniMax-M3` to the Anthropic- * compatible endpoint, which silently ignores `cache_control` (full * input price every turn). The OpenAI-compatible endpoint * (`/v1/chat/completions`) does passive caching — but M3 emits thinking * twice there: in `reasoning_content`/`reasoning` fields (consumed as * proper thinking blocks) and again inline in `content` as * ``. It also alternates between the two reasoning * fields across chunks, which the openai-completions driver treats as * new blocks and re-streams from the start. * * The upstream fix (earendil-works/pi-mono b85b91c9, `skipThinkingBlock`) * is unreleased as of 0.79.1. This extension routes to the OpenAI- * compatible endpoint and rewrites the event stream in flight. The * stream wrapper is in `./src/core/clean-stream.ts`; the override-file * parser in `./src/core/overrides.ts`; the provider metadata in * `./src/core/providers.ts`. This file is the orchestrator: discovers * the agent dir, loads overrides, and registers the two providers. * * Fail-soft behavior (S02) * ------------------------ * The orchestrator must not crash the whole session when this extension * loads on a host that refuses to accept a provider name conflict in * `pi.registerProvider(spec.name, ...)` (validation error throws). Each * `pi.registerProvider` call is wrapped in try/catch; on throw we record * a TUI warning naming the provider and continue with the next spec * instead of letting the exception propagate. * * Warnings are deferred to `session_start` because `ctx.ui.notify` is * not available in the extension factory — the same precedent S01 * established for `overrides.invalid` entries. * * Top-level `streamSimple` bridge * ------------------------------- * `streamSimple(model, ctx, opts)` exists as a top-level export on * all three Pi-family hosts (vanilla `@earendil-works/pi-ai@0.79.1`, * gsd-pi's `@gsd/pi-ai` symlink facade, omp's `@oh-my-pi/pi-ai@16.0.2`). * It dispatches to the built-in driver for `model.api`; the wrapper * passes `{...model, api: "openai-completions", compat: M3_COMPAT}` to * reach the openai-completions driver without going through * `getApiProvider` (which omp dropped in 16.0.2 — see MEM006). * * The explicit `compat: M3_COMPAT` in the spread is structural, not * cosmetic: `pi.registerProvider` accepts a `compat` config and the * model's `compat` is set at registration time, but `buildCompat` (in * the host pi-ai) writes `compat: undefined` when the registered value * is not recognized as a known compat. omp's openai-completions driver * dereferences `model.compat.streamIdleTimeoutMs` on the first packet * and crashes with "undefined is not an object" when the field is * missing (S04 T04 evidence record 150700a3, S05 T01 evidence record * 745198ad). Re-passing the canonical `M3_COMPAT` object here — instead * of relying on whatever the registration path produced — guarantees * the value the wrapper actually receives is the value the driver * reads. This is the runtime half of MEM017 (MEM024 / MEM025); the * source half (declaring `streamIdleTimeoutMs` on M3_COMPAT) shipped * in S05. * * Cross-host oauth registration (S01/M003) * --------------------------------------- * `makeProvider()` also passes `oauth: spec.oauth` into * `pi.registerProvider`. This is the registration-shape change that * surfaces our providers in omp's `/login` selector: omp's * `AuthStorage.login()` invokes our `login()` callback (which closes * over `callbacks.onPrompt` — NOT the factory's `ctx.ui`, which is * `undefined` long after the extension factory has returned) and * persists the returned string as `api_key` via the * `typeof result === "string"` branch in omp's auth storage. On * pi/gsd the `oauth` block is accepted by `registerProvider`'s schema * but the host's own native `/login` API-key picker is what users * actually interact with — our entry is a no-op on those hosts. * Vanilla pi 0.79.1's `registerProvider` additionally validates * `config.oauth` against `Omit`, which * requires `login`, `refreshToken`, and `getApiKey`; the S02 fail-soft * machinery catches the resulting validation throw and defers a * `session_start` warning naming the provider, so on vanilla pi the * provider is silently skipped rather than crashing the session. See * AGENTS.md §3 and `.gsd/milestones/M003/slices/S01/S01-RESEARCH.md` * for the documented cross-host asymmetry. * * Host-branched direct registration (S03/M003) * -------------------------------------------- * `pi.registerProvider({oauth})` is the documented cross-host shape, * but D-001/MEM035 surfaced a real gap: on a multi-host dev machine * `validateProviderConfiguration` on omp 16.0.2 can throw before the * oauth-bearing dispatch is reached, and the S02 fail-soft catch in * `makeProvider` swallows the throw — so `registerOAuthProvider` is * never called and the provider is missing from `omp auth-broker * list --json`. The fix is a second, host-branched direct call to * `registerOAuthProvider` that bypasses the model-registry validation * path entirely. See `.gsd/milestones/M003/slices/S03/S03-RESEARCH.md` * for the root-cause analysis. * * The dispatch lives inside `registerOmpOAuth()` and is gated on * `detectHost(...)` returning `"omp"` — pi and gsd paths are * untouched, so the behavior on those hosts is identical to pre-S03. * The dynamic `import("@oh-my-pi/pi-ai/oauth")` is **only** invoked * on the omp branch because the `/oauth` subpath does not exist in * `@earendil-works/pi-ai` (vanilla pi); importing it unconditionally * would crash the extension on pi/gsd. If the import fails for any * reason (package missing, future shape change), the helper catches * the error, pushes a deferred `session_start` warning via the * shared `warnings` buffer, and returns without crashing the session — * preserving the S02 fail-soft contract. `registerOAuthProvider` itself * is also wrapped in try/catch for the same reason. The `sourceId` * field is read from `process.env.npm_package_name` / * `process.env.npm_package_version` when set (publish-time), with a * hard-coded `"@razllivan/pi-minimax-m3-caching-fix@0.2.4"` fallback * that matches the current `package.json` so `unregisterOAuthProviders` * can be invoked on reload without orphaning prior registrations. * * Why the dual-registration path is safe even when it succeeds twice: * `registerOAuthProvider` overwrites by `id`, so calling it from both * the `pi.registerProvider({oauth})` path AND the direct path simply * replaces the prior descriptor with an identical one. The order * (direct first, then `pi.registerProvider`) does not matter for the * descriptor state, but it does matter for the fail-soft contract: if * `pi.registerProvider` later throws, the descriptor from the direct * call is already in omp's `customOAuthProviders` map. * * See `.gsd/milestones/M003/slices/S03/S03-SUMMARY.md` for the slice * summary and verification record. * * Host-branched `apiKey` omission on omp (M004) * --------------------------------------------- * The previous shape passed `apiKey: spec.apiKey` (the literal string * `"$MINIMAX_API_KEY"`) to `pi.registerProvider` on every host. On omp * 16.0.2 that registered value is installed as a runtime override at * the top of AuthStorage.getApiKey's priority chain * (`runtimeOverrides > configOverrides > api_key credentials > * oauth credentials > env var`). When `process.env.MINIMAX_API_KEY` is * unset (the common case after the user authenticates via /login, since * /login persists the key to the auth-broker store and not to the * process env), omp writes the literal `"$MINIMAX_API_KEY"` to the env * var and the next chat sends `Authorization: Bearer $MINIMAX_API_KEY` * → upstream 401/1004 — even though a valid oauth credential is sitting * lower in the priority chain and would have worked. (D007) * * The fix is host-branched: on `host === "omp"` we omit `apiKey` from * the `pi.registerProvider` config entirely (the key must not appear * AT ALL, not even `apiKey: undefined` — that still installs a runtime * override that resolves to `undefined`). pi/gsd keep the * `apiKey: spec.apiKey` shape unchanged so the existing env-var * fallback (interpolated by `migrateLegacyRegisterProviderConfigValue` * at request time when the env var is set) still works for users on * those hosts who never use /login. See * `.gsd/milestones/M004/slices/S01/S01-RESEARCH.md` for the upstream * priority-chain analysis and D007 for the recorded decision. The * structural lock is `tests/m04-omp-apikey-shadow-check.mjs`. */ import { stat } from "node:fs/promises"; import { homedir } from "node:os"; import { streamSimple } from "@earendil-works/pi-ai"; import type { Api, Context, Model, SimpleStreamOptions } from "@earendil-works/pi-ai"; import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; import { cleanStream } from "./src/core/clean-stream"; import { loadOverrides } from "./src/core/overrides"; import { M3_COMPAT, M3_DEFAULTS, PROVIDERS, type ProviderSpec } from "./src/core/providers"; /** Three pi-family hosts this extension targets. Each one ships its own * copy of `getAgentDir()` from its bundled `@…/pi-coding-agent` package. * The strings are also dynamic `import()` specifiers — order matters. * Exported so the regression test in `tests/s06-resolve-agent-dir.mjs` * can read the package names without re-declaring them. */ export const AGENT_DIR_PROVIDERS = { pi: "@earendil-works/pi-coding-agent", omp: "@oh-my-pi/pi-coding-agent", gsd: "@gsd/pi-coding-agent", } as const; export type Host = keyof typeof AGENT_DIR_PROVIDERS; /** Pure host detection. Self-contained (inlines the path-normalization * step) so the regression test can `eval` the function body in isolation * without dragging in module-level state. Cross-platform: Windows * argv[1] is `C:\…\node_modules\@opengsd\gsd-pi\dist\loader.js`, POSIX * is `/opt/homebrew/lib/node_modules/@opengsd/gsd-pi/dist/loader.js` — * both match the substring `@opengsd/gsd-pi` after normalization. */ export function detectHost( argv1: string | undefined, bunVersion: string | undefined, ): Host | undefined { // omp is uniquely identified by running under Bun — nothing else in // scope uses Bun, and omp's binary is Bun-compiled (MEM022). if (typeof bunVersion === "string" && bunVersion.length > 0) return "omp"; const norm = String(argv1 ?? "").replace(/\\/g, "/").toLowerCase(); // Order matters: gsd's path also contains `pi-coding-agent` (the // @gsd/pi-coding-agent re-export), so check the more specific gsd // substring first to avoid a false positive on `pi`. if (norm.includes("@opengsd/gsd-pi") || norm.includes("gsd-pi/dist/loader")) { return "gsd"; } if ( norm.includes("@earendil-works/pi-coding-agent") || norm.includes("pi-coding-agent/dist/cli") ) { return "pi"; } return undefined; } /** Expand a leading `~` or `~/` to the user's home directory. Mirrors the * POSIX-shell convention most users will reach for; we do not support * `~user/` syntax because that requires `/etc/passwd` lookup and is not * portable to Windows. */ function expandHome(p: string): string { if (p === "~") return homedir(); if (p.startsWith("~/") || p.startsWith("~\\")) return homedir() + p.slice(1); return p; } /** Try one package specifier; return its `getAgentDir()` if it imports * cleanly and exposes the function, else `undefined`. */ async function tryPackage(pkg: string): Promise { try { const mod = await import(pkg); if (typeof mod.getAgentDir === "function") return mod.getAgentDir(); } catch { // Package not installed in this runtime — try the next one. } return undefined; } /** Order in which to probe `getAgentDir()` packages for a given host. * The matching host's package is tried first (it should be the source of * truth on a single-host install); vanilla pi is always second because * the legacy-pi-compat rewrite (MEM013) means `@earendil-works/pi-coding-agent` * is the resolved module under omp on most setups; the remaining host is * the last resort. Exported for the regression test; takes the providers * map as an argument (defaulting to the module constant) so the test can * inject a fixture without module-scope gymnastics. */ export function probeOrder( host: Host | undefined, providers: typeof AGENT_DIR_PROVIDERS = AGENT_DIR_PROVIDERS, ): readonly string[] { if (host === "pi") { return [providers.pi, providers.omp, providers.gsd]; } if (host === "gsd") { return [providers.gsd, providers.pi, providers.omp]; } if (host === "omp") { return [providers.omp, providers.pi, providers.gsd]; } // No host detected — fall through to the legacy order (vanilla first, // then the others). Single-host installs still work. return [providers.pi, providers.omp, providers.gsd]; } async function resolveAgentDir(): Promise { // Priority 1: explicit user/test override. Validated for existence so // a stale value pointing at a deleted dir falls through to detection // rather than silently masking override-file loading. const override = process.env.M3_CLEAN_AGENT_DIR; if (override && override.length > 0) { const expanded = expandHome(override); try { const st = await stat(expanded); if (st.isDirectory()) { debugAgentDir("override", expanded); return expanded; } } catch { // Override dir missing or unreadable — log and fall through. console.debug( `[m3-clean] M3_CLEAN_AGENT_DIR=${override} is not a readable directory; falling through to host detection`, ); } } // Priority 2: host detection (bun runtime fingerprint + argv[1] substring). const host = detectHost(process.argv[1], process.versions.bun); // Priority 3: per-host package probe, matching package first. for (const pkg of probeOrder(host)) { const dir = await tryPackage(pkg); if (dir) { debugAgentDir(host ?? "unknown", dir); return dir; } } debugAgentDir(host ?? "unknown", undefined); return undefined; } /** One-line diagnostic of the resolved agent dir. `console.debug` is the * right tier: silent under default Node/Bun, visible if the user * redirects stderr (`node ... 2>debug.log` or `DEBUG=*`). */ function debugAgentDir(host: string | "unknown", agentDir: string | undefined): void { console.debug(`[m3-clean] host=${host} agentDir=${agentDir ?? ""}`); } /** Literal `sourceId` fallback for the dual-registration path. omp's * `unregisterOAuthProviders(sourceId)` removes every provider whose * `sourceId` matches, so the value must be stable across reloads. The * publish-time env-var path (preferred when set) produces the same * shape; this literal is the offline / non-pnpm fallback. Keep in * sync with `package.json` `name` and `version` at bump time. */ const FALLBACK_SOURCE_ID = "@razllivan/pi-minimax-m3-caching-fix@0.2.4"; /** Resolve the stable `sourceId` string for `registerOAuthProvider`. * Reads `process.env.npm_package_name` and `npm_package_version` when * set (publish-time / `pnpm exec` invocations), falling back to the * literal `FALLBACK_SOURCE_ID` constant otherwise. The runtime must * not `require("../package.json")` because that path is not always * resolvable on hosts that load the extension from a bundled cache * (e.g. omp's `~/.bun/install/cache/...`). */ function resolveSourceId(): string { const name = process.env.npm_package_name; const version = process.env.npm_package_version; if (typeof name === "string" && name.length > 0 && typeof version === "string" && version.length > 0) { return `${name}@${version}`; } console.debug(`[m3-clean] registerOmpOAuth: npm_package_name/version not set; using fallback sourceId ${FALLBACK_SOURCE_ID}`); return FALLBACK_SOURCE_ID; } /** Host-branched direct `registerOAuthProvider` call (S03/M003). * * Returns immediately when the active host is not omp (preserve pi/gsd * behavior unchanged). On omp, dynamically imports * `@oh-my-pi/pi-ai/oauth`, destructures `registerOAuthProvider`, and * registers the spec's oauth descriptor under the provider id with a * stable `sourceId`. The dynamic import is mandatory: the `/oauth` * subpath exists on omp's `@oh-my-pi/pi-ai@16.0.2` (see * `node_modules/@oh-my-pi/pi-ai/package.json` exports) but NOT on * vanilla pi's `@earendil-works/pi-ai`, so importing it * unconditionally would crash the extension on pi/gsd. * * Fail-soft contract (S02): import errors and `registerOAuthProvider` * throws both push a deferred warning into `warnings` and return * without propagating — the host's session continues regardless of * whether the omp auth-broker descriptor was registered. */ async function registerOmpOAuth( spec: ProviderSpec, warnings: string[], ): Promise { // Honor the same host-detection chain as `resolveAgentDir` so a // multi-host dev machine routes correctly (MEM026 / S06). const host = detectHost(process.argv[1], process.versions.bun); if (host !== "omp") return; let registerOAuthProvider: ((provider: unknown) => void) | undefined; try { // Dynamic import — package is only present on omp. The `as // unknown` cast is local to this call site; the spec.oauth shape // is structurally compatible with omp's `OAuthProviderInterface` // at runtime (the host passes a superset of `OauthPromptCallbacks` // as `callbacks`, and our narrower parameter reads only the // fields it declared). const mod = (await import("@oh-my-pi/pi-ai/oauth" as string)) as { registerOAuthProvider?: (provider: unknown) => void; }; registerOAuthProvider = mod.registerOAuthProvider; if (typeof registerOAuthProvider !== "function") { warnings.push( `m3-clean: @oh-my-pi/pi-ai/oauth exported but registerOAuthProvider is not a function on host "omp". Skipping host-branched registration for "${spec.name}".`, ); return; } } catch (err) { const reason = err instanceof Error ? err.message : String(err); warnings.push( `m3-clean: failed to dynamically import @oh-my-pi/pi-ai/oauth on host "omp" for "${spec.name}" — ${reason}. Skipping host-branched registration.`, ); return; } try { // Build the omp descriptor structurally. The runtime shape is // `{id, name, sourceId?, login(callbacks) → Promise, // refreshToken?, getApiKey?}`. Our `spec.oauth` already satisfies // that (S01 T03 widened `OauthConfig` to include `refreshToken` // and `getApiKey`); the `as unknown as` cast bridges the // narrow-vs-OAuthLoginCallbacks parameter shape difference (the // host injects a superset and our login only reads the onPrompt // channel, so runtime is safe). const descriptor = { ...spec.oauth, id: spec.name, sourceId: resolveSourceId(), } as unknown as Parameters>[0]; registerOAuthProvider(descriptor); } catch (err) { const reason = err instanceof Error ? err.message : String(err); warnings.push( `m3-clean: registerOAuthProvider threw on host "omp" for "${spec.name}" — ${reason}. The host-branched descriptor was NOT registered.`, ); return; } } async function makeProvider( pi: ExtensionAPI, spec: ProviderSpec, contextWindow: number, streamSimpleFn: typeof streamSimple, warnings: string[], ): Promise { const api = spec.name as Api; // custom api id so only these models hit our handler // Computed once for the whole provider build. `registerOmpOAuth` // re-derives the host internally per MEM026; do not refactor to // share — the duplicated call is intentional isolation, not drift. const host = detectHost(process.argv[1], process.versions.bun); // Host-branched direct registration FIRST so the oauth descriptor // is in omp's `customOAuthProviders` map even if the subsequent // `pi.registerProvider` call throws on a model-registry validation // edge (the D-001/MEM035 root cause per S03-RESEARCH §3). await registerOmpOAuth(spec, warnings); try { if (host === "omp") { // omp branch MUST NOT install a runtime apiKey override. // On omp 16.0.2, `installProviderApiKey` reads the literal // `$MINIMAX_API_KEY` from the provider config and writes it // to `process.env.MINIMAX_API_KEY` if the env var is unset, // placing a non-functional placeholder at the top of // AuthStorage.getApiKey's `runtimeOverrides > configOverrides // > api_key credentials > oauth credentials > env var` // priority chain. The next chat then sends // `Authorization: Bearer $MINIMAX_API_KEY` → upstream 401/1004. // Omitting the key entirely lets the chain fall through to // the oauth credential the user saved via /login (D007). // The key must NOT appear AT ALL here — even `apiKey: // undefined` would still install a runtime override that // resolves to `undefined`. pi.registerProvider(spec.name, { baseUrl: spec.baseUrl, oauth: spec.oauth, api, streamSimple: (model: Model, context: Context, options?: SimpleStreamOptions) => { const base = streamSimpleFn({ ...model, api: "openai-completions", compat: M3_COMPAT }, context, options); return cleanStream(base); }, models: [{ id: "MiniMax-M3", name: spec.label, reasoning: true, input: ["text", "image"], cost: { ...M3_DEFAULTS.cost }, contextWindow, maxTokens: M3_DEFAULTS.maxTokens, compat: M3_COMPAT, }], }); } else { pi.registerProvider(spec.name, { baseUrl: spec.baseUrl, apiKey: spec.apiKey, oauth: spec.oauth, api, streamSimple: (model: Model, context: Context, options?: SimpleStreamOptions) => { const base = streamSimpleFn({ ...model, api: "openai-completions", compat: M3_COMPAT }, context, options); return cleanStream(base); }, models: [{ id: "MiniMax-M3", name: spec.label, reasoning: true, input: ["text", "image"], cost: { ...M3_DEFAULTS.cost }, contextWindow, maxTokens: M3_DEFAULTS.maxTokens, compat: M3_COMPAT, }], }); } return true; } catch (err) { // registerProvider throws on validation error (e.g. duplicate name // when the host's built-in provider collides with our spec). Surface // via the deferred session_start notification and skip this provider. const reason = err instanceof Error ? err.message : String(err); warnings.push( `m3-clean: failed to register provider "${spec.name}" — ${reason}. Skipping.`, ); return false; } } export default async function (pi: ExtensionAPI) { const agentDir = await resolveAgentDir(); const overrides = agentDir ? await loadOverrides(agentDir) : { contextWindow: M3_DEFAULTS.contextWindow, invalid: [] }; // Always run the registration loop. `streamSimple` is a top-level // export on all three supported hosts; the openai-completions driver // is reached by passing `{...model, api: "openai-completions"}` to it. // If a host's `streamSimple` cannot handle that api, the call itself // errors — that's the right surface, not a load-time warning. const warnings: string[] = []; for (const spec of PROVIDERS) { // `makeProvider` is async because the host-branched direct // `registerOAuthProvider` dispatch (S03/M003) is itself a // dynamic import. We `await` each call so the deferred-warning // buffer is fully populated before the session_start handler is // registered below — otherwise an omp-only registration failure // could race the handler attachment and be silently lost. await makeProvider(pi, spec, overrides.contextWindow, streamSimple, warnings); } // Surface every deferred warning at session_start. Two producers feed // this same handler: the override-file validation errors from S01 // (`overrides.invalid`) and the fail-soft warnings from S02 (driver // missing, registerProvider thrown). They are merged into one // session_start handler so the user sees one grouped banner instead // of several competing toasts. const allWarnings: string[] = warnings.slice(); for (const err of overrides.invalid) { allWarnings.push( `m3-clean-overrides: ${err.provider}/${err.modelId}.${err.field} — ${err.reason}. Falling back to default.`, ); } if (allWarnings.length > 0) { pi.on("session_start", (_event, ctx) => { for (const msg of allWarnings) ctx.ui.notify(msg, "warning"); }); } }