/** * Model/provider visibility control — ON/OFF what appears in pi's `/model` * selector (and in the provider list /login shows). * * pi's own `filterModels` provider hook is only forwarded from pi-ai *base* * providers, never from extensions (provider-composer.js forwards only * `base?.filterModels`). Extensions therefore control visibility at the * SOURCE: the `models` array passed to registerProvider and the arrays * returned by refreshModels (which pi persists to its model-store cache). * * Two layers, both optional (default = everything visible): * * 1. Provider-level ON/OFF * - config file: { "oc-go": { "enabled": false } } * - env override: PI_OTHER_PROVIDER_DISABLE=oc-zen,commandcode * ("*" disables every provider). A disabled provider is not * registered at all — it disappears from /model AND /login. * * 2. Model-level filtering (glob patterns, `*` = any chars) * - showOnly: keep only matching ids → { "oc-zen": { "showOnly": ["claude*", "deepseek*"] } } * - hide: drop matching ids → { "oc-go": { "hide": ["mimo-*"] } } * - showOnly wins first, then hide removes from the remainder. * * Config file location: /pi-other-provider.json * (override with PI_OTHER_PROVIDER_CONFIG=). * A missing/empty/malformed file behaves as "no restrictions". * * Matching is case-sensitive and applies to the model id only. */ import { getAgentDir } from "@earendil-works/pi-coding-agent" import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs" import { dirname, join } from "node:path" export interface ProviderVisibility { /** false → provider is not registered at all. Default: true. */ enabled?: boolean /** If set and non-empty, ONLY models matching at least one pattern are shown. */ showOnly?: string[] /** Models matching any pattern are hidden. Applied after showOnly. */ hide?: string[] } export type VisibilityConfig = Record export const DEFAULT_CONFIG_FILE = "pi-other-provider.json" export const PI_OTHER_PROVIDER_CONFIG_ENV = "PI_OTHER_PROVIDER_CONFIG" export const PI_OTHER_PROVIDER_DISABLE_ENV = "PI_OTHER_PROVIDER_DISABLE" /** * Convert a glob-ish pattern ("claude-*", "deepseek-v4-pro", "*") into a RegExp. * `*` matches any run of characters; everything else matches literally. */ export function patternToRegExp(pattern: string): RegExp { const source = pattern .split("*") .map((part) => part.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")) .join(".*") return new RegExp(`^${source}$`) } /** Match a value against a glob-ish pattern ("*" matches everything). */ export function matchesPattern(value: string, pattern: string): boolean { if (pattern === "*") return true if (pattern === value) return true return patternToRegExp(pattern).test(value) } function matchesAny(value: string, patterns: string[] | undefined): boolean { if (!patterns || patterns.length === 0) return false return patterns.some((pattern) => matchesPattern(value, pattern)) } // --------------------------------------------------------------------------- // Config loading // --------------------------------------------------------------------------- /** * Resolve the config file path for READING: PI_OTHER_PROVIDER_CONFIG env * override, else /pi-other-provider.json. Returns null when neither * exists. */ export function resolveConfigPath(): string | null { const override = process.env[PI_OTHER_PROVIDER_CONFIG_ENV] if (override) return override try { const path = join(getAgentDir(), DEFAULT_CONFIG_FILE) return existsSync(path) ? path : null } catch { return null } } /** * Resolve the config file path for WRITING: env override if set, else * /pi-other-provider.json. Always returns a concrete path (the * parent directory is created on save). */ export function getConfigPath(): string { const override = process.env[PI_OTHER_PROVIDER_CONFIG_ENV] if (override) return override return join(getAgentDir(), DEFAULT_CONFIG_FILE) } function defaultConfig(): VisibilityConfig { return {} } /** * Read + parse the config file. Any failure (missing file, JSON syntax error, * wrong shape) yields an empty config — visibility is best-effort and must * never break provider registration. */ export function loadVisibilityConfig(): VisibilityConfig { const path = resolveConfigPath() if (!path) return defaultConfig() try { const raw = readFileSync(path, "utf8") const parsed: unknown = JSON.parse(raw) if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) { return defaultConfig() } const config: VisibilityConfig = {} for (const [providerId, entry] of Object.entries( parsed as Record, )) { if (typeof entry !== "object" || entry === null) continue const { enabled, showOnly, hide } = entry as Record config[providerId] = { enabled: typeof enabled === "boolean" ? enabled : undefined, showOnly: Array.isArray(showOnly) ? showOnly.filter((p) => typeof p === "string") : undefined, hide: Array.isArray(hide) ? hide.filter((p) => typeof p === "string") : undefined, } } return config } catch { return defaultConfig() } } /** * Persist a visibility config to disk (getConfigPath). Writes compact JSON and * creates the parent directory if needed. Never throws — a write failure is * swallowed (best-effort) and reported via the return value. */ export function saveVisibilityConfig(config: VisibilityConfig): boolean { try { const path = getConfigPath() mkdirSync(dirname(path), { recursive: true }) writeFileSync(path, JSON.stringify(config, null, "\t") + "\n", "utf8") return true } catch { return false } } // --------------------------------------------------------------------------- // Provider-level ON/OFF // --------------------------------------------------------------------------- /** * Whether a provider should be registered at all. * Disabled via config file ({ id: { enabled: false } }) or the * PI_OTHER_PROVIDER_DISABLE env var (comma-separated ids, "*" = all). */ export function isProviderEnabled(providerId: string): boolean { const envDisable = process.env[PI_OTHER_PROVIDER_DISABLE_ENV] if (envDisable) { const disabled = envDisable .split(",") .map((entry) => entry.trim()) .filter(Boolean) if (disabled.includes("*") || disabled.includes(providerId)) return false } const config = loadVisibilityConfig() return config[providerId]?.enabled !== false } // --------------------------------------------------------------------------- // Model-level filtering // --------------------------------------------------------------------------- /** * Apply the showOnly/hide rules for a provider to a model list. * Returns a new array; the input is never mutated. Rules apply to model ids. * With no rules configured, the list is returned unchanged. */ export function filterModels( providerId: string, models: readonly T[], ): T[] { const config = loadVisibilityConfig() const visibility = config[providerId] if (!visibility) return [...models] let filtered = [...models] if (visibility.showOnly && visibility.showOnly.length > 0) { filtered = filtered.filter((model) => matchesAny(model.id, visibility.showOnly)) } if (visibility.hide && visibility.hide.length > 0) { filtered = filtered.filter((model) => !matchesAny(model.id, visibility.hide)) } return filtered } /** * Convenience: filter, then report how many were hidden (for logs/README). */ export function countHidden( providerId: string, models: readonly T[], ): { visible: T[]; hidden: number } { const visible = filterModels(providerId, models) return { visible, hidden: models.length - visible.length } }