287 lines
9.8 KiB
TypeScript
287 lines
9.8 KiB
TypeScript
import { execFile as nodeExecFile } from "node:child_process";
|
|
import { promisify } from "node:util";
|
|
import type { AppConfig } from "../config.js";
|
|
import { secretValue } from "../config/secret-bundle.js";
|
|
import { loadSettings, type Settings } from "../settings/settings-store.js";
|
|
import {
|
|
splitCanonicalModelId,
|
|
type RuntimeModelCatalog,
|
|
} from "../models/runtime-model-catalog.js";
|
|
import {
|
|
configuredPiProviderApiKey,
|
|
PI_MANAGED_CONFIG_ERROR_MESSAGE,
|
|
isPiManagedConfigError,
|
|
readConfiguredPiAgentFile,
|
|
} from "./managed-config.js";
|
|
import { createPiProviderSmoke, type PiProviderSmoke } from "./provider-smoke.js";
|
|
import { loadPiAuthProviders } from "./auth-providers.js";
|
|
import {
|
|
piProviderCredentialStatus,
|
|
type PiCredentialStatus,
|
|
} from "./provider-credentials.js";
|
|
|
|
const execFile = promisify(nodeExecFile);
|
|
const REASONING_CHOICES = ["low", "medium", "high"] as const;
|
|
const VERSION_PATTERN = /^(?:pi(?:\s+version)?\s+)?v?(\d+(?:\.\d+){1,3}(?:[-+][0-9A-Za-z.-]+)?)$/;
|
|
const MAX_LOG_LINES = 200;
|
|
const MAX_LOG_LINE_LENGTH = 4_096;
|
|
const MAX_EXEC_OUTPUT_BYTES = 64 * 1024;
|
|
|
|
export type PiReasoning = typeof REASONING_CHOICES[number];
|
|
|
|
export interface PiInstallationConfig {
|
|
provider?: string;
|
|
model?: string;
|
|
reasoning?: PiReasoning;
|
|
}
|
|
|
|
export interface PiStatus {
|
|
hostPlatform: "linux" | "macos" | "windows";
|
|
version?: string;
|
|
ready: boolean;
|
|
credentials: PiCredentialStatus;
|
|
config: PiInstallationConfig;
|
|
checkedAt: string;
|
|
message?: string;
|
|
}
|
|
|
|
export interface PiTestResult {
|
|
ready: boolean;
|
|
checkedAt: string;
|
|
message?: string;
|
|
}
|
|
|
|
export interface PiLogs {
|
|
lines: string[];
|
|
checkedAt: string;
|
|
}
|
|
|
|
export interface PiExecFileOptions {
|
|
timeout: number;
|
|
maxBuffer: number;
|
|
}
|
|
|
|
export type PiExecFile = (
|
|
command: string,
|
|
args: string[],
|
|
options: PiExecFileOptions,
|
|
) => Promise<{ stdout: string; stderr: string }>;
|
|
|
|
export interface PiManagementService {
|
|
status(): Promise<PiStatus>;
|
|
test(): Promise<PiTestResult>;
|
|
logs(): Promise<PiLogs>;
|
|
}
|
|
|
|
export class PiManagementError extends Error {
|
|
constructor(
|
|
public readonly code: "pi_management_unavailable",
|
|
message: string,
|
|
) {
|
|
super(message);
|
|
}
|
|
}
|
|
|
|
interface PiManagementDeps {
|
|
execute?: PiExecFile;
|
|
modelCatalog: RuntimeModelCatalog;
|
|
smokeProvider?: PiProviderSmoke;
|
|
readSettings?: () => Settings;
|
|
readLogs?: () => string | Promise<string>;
|
|
credentialStatus?: (provider: string | undefined) => PiCredentialStatus;
|
|
now?: () => Date;
|
|
}
|
|
|
|
export function createPiManagement(config: AppConfig, deps: PiManagementDeps): PiManagementService {
|
|
const platform = config.hostPlatform ?? process.platform;
|
|
const hostPlatform = platform === "darwin" ? "macos"
|
|
: platform === "windows" || platform === "win32" ? "windows" : "linux";
|
|
const now = deps.now ?? (() => new Date());
|
|
const diagnostics: string[] = [];
|
|
const addDiagnostic = (message: string): void => {
|
|
diagnostics.push(`${now().toISOString()} ${redact(message)}`);
|
|
if (diagnostics.length > MAX_LOG_LINES) diagnostics.splice(0, diagnostics.length - MAX_LOG_LINES);
|
|
};
|
|
const execute = deps.execute ?? defaultExecFile;
|
|
const readSettings = deps.readSettings ?? (() => loadSettings(config));
|
|
const readLogs = deps.readLogs ?? (() => diagnostics.join("\n"));
|
|
const smokeProvider = deps.smokeProvider ?? createPiProviderSmoke(config, {
|
|
modelCatalog: deps.modelCatalog,
|
|
});
|
|
const credentialStatus = deps.credentialStatus ?? ((provider: string | undefined) => {
|
|
try {
|
|
const model = deps.modelCatalog.defaultInteraction
|
|
? deps.modelCatalog.sessionModels().find((entry) => entry.id === deps.modelCatalog.defaultInteraction)
|
|
: undefined;
|
|
const credentialName = model?.authentication.mode === "secret_env"
|
|
? model.authentication.apiKeyEnv
|
|
: undefined;
|
|
const configuredApiKey = credentialName ? `$${credentialName}` : configuredPiProviderApiKey(
|
|
readConfiguredPiAgentFile("models.json", true),
|
|
provider,
|
|
);
|
|
return piProviderCredentialStatus({
|
|
provider,
|
|
authProviders: credentialName ? new Set() : loadPiAuthProviders(),
|
|
resolveCredentialValue: () => credentialName
|
|
? secretValue(config, credentialName)
|
|
: config.modelCatalogFile ? undefined : secretValue(config, "THT_MODEL_API_KEY"),
|
|
credentialFile: credentialName ? undefined : config.modelApiKeyFile,
|
|
configuredApiKey,
|
|
});
|
|
} catch {
|
|
return "missing";
|
|
}
|
|
});
|
|
|
|
const version = async (timeoutMs = config.piManagementTimeoutMs): Promise<string> => {
|
|
let output: { stdout: string; stderr: string };
|
|
try {
|
|
// The Pi executable and every argument are installation-owned constants. Do not add a shell.
|
|
output = await execute(config.piBin, ["--version"], {
|
|
timeout: timeoutMs,
|
|
maxBuffer: MAX_EXEC_OUTPUT_BYTES,
|
|
});
|
|
} catch (error) {
|
|
if (isTimeout(error)) {
|
|
throw new PiManagementError("pi_management_unavailable", "Pi smoke check timed out");
|
|
}
|
|
throw new PiManagementError("pi_management_unavailable", "Pi runtime is unavailable");
|
|
}
|
|
const matched = VERSION_PATTERN.exec(output.stdout.trim());
|
|
if (!matched) throw new PiManagementError("pi_management_unavailable", "Pi runtime returned an invalid version");
|
|
return matched[1];
|
|
};
|
|
|
|
const installationConfig = (): PiInstallationConfig => {
|
|
const settings = readSettings();
|
|
const reasoning = config.defaults.thinking ?? settings.thinking;
|
|
const selected = deps.modelCatalog.defaultInteraction
|
|
? splitCanonicalModelId(deps.modelCatalog.defaultInteraction)
|
|
: undefined;
|
|
return {
|
|
...(selected ? selected : {}),
|
|
...(isReasoning(reasoning) ? { reasoning } : {}),
|
|
};
|
|
};
|
|
|
|
return {
|
|
async status(): Promise<PiStatus> {
|
|
const checkedAt = now().toISOString();
|
|
const current = installationConfig();
|
|
const credentials = credentialStatus(current.provider);
|
|
try {
|
|
const currentVersion = await version();
|
|
addDiagnostic("Pi version probe succeeded");
|
|
return { hostPlatform, version: currentVersion, ready: true, credentials, config: current, checkedAt };
|
|
} catch (error) {
|
|
const message = stableMessage(error, "Pi runtime is unavailable");
|
|
addDiagnostic(message);
|
|
return { hostPlatform, ready: false, credentials, config: current, checkedAt, message };
|
|
}
|
|
},
|
|
|
|
async test(): Promise<PiTestResult> {
|
|
const checkedAt = now().toISOString();
|
|
const deadline = Date.now() + config.piManagementTimeoutMs;
|
|
let timer: NodeJS.Timeout | undefined;
|
|
try {
|
|
const check = async (): Promise<void> => {
|
|
await version(remainingBudget(deadline));
|
|
const current = installationConfig();
|
|
if (!current.provider || !current.model || !current.reasoning) {
|
|
throw new PiManagementError(
|
|
"pi_management_unavailable",
|
|
"Pi installation configuration is incomplete",
|
|
);
|
|
}
|
|
await smokeProvider({
|
|
provider: current.provider,
|
|
model: current.model,
|
|
reasoning: current.reasoning,
|
|
timeoutMs: remainingBudget(deadline),
|
|
});
|
|
};
|
|
await Promise.race([
|
|
check(),
|
|
new Promise<never>((_resolve, reject) => {
|
|
timer = setTimeout(
|
|
() => reject(new PiManagementError("pi_management_unavailable", "Pi smoke check timed out")),
|
|
config.piManagementTimeoutMs,
|
|
);
|
|
}),
|
|
]);
|
|
addDiagnostic("Pi smoke check succeeded");
|
|
return { ready: true, checkedAt };
|
|
} catch (error) {
|
|
return smokeFailure(stableMessage(error, "Pi provider smoke check failed"), checkedAt, addDiagnostic);
|
|
} finally {
|
|
if (timer) clearTimeout(timer);
|
|
}
|
|
},
|
|
|
|
async logs(): Promise<PiLogs> {
|
|
let source = "";
|
|
try {
|
|
source = await readLogs();
|
|
} catch {
|
|
source = "Pi diagnostics are unavailable";
|
|
}
|
|
const lines = source
|
|
.split(/\r?\n/u)
|
|
.filter((line) => line.length > 0)
|
|
.slice(-MAX_LOG_LINES)
|
|
.map((line) => redact(line.slice(0, MAX_LOG_LINE_LENGTH)));
|
|
return { lines, checkedAt: now().toISOString() };
|
|
},
|
|
};
|
|
}
|
|
|
|
async function defaultExecFile(command: string, args: string[], options: PiExecFileOptions) {
|
|
const result = await execFile(command, args, {
|
|
timeout: options.timeout,
|
|
maxBuffer: options.maxBuffer,
|
|
windowsHide: true,
|
|
});
|
|
return { stdout: String(result.stdout), stderr: String(result.stderr) };
|
|
}
|
|
|
|
function isReasoning(value: unknown): value is PiReasoning {
|
|
return typeof value === "string" && (REASONING_CHOICES as readonly string[]).includes(value);
|
|
}
|
|
|
|
function isTimeout(error: unknown): boolean {
|
|
return Boolean(
|
|
error && typeof error === "object" && (
|
|
(error as { code?: unknown }).code === "ETIMEDOUT"
|
|
|| (error as { killed?: unknown }).killed === true
|
|
),
|
|
);
|
|
}
|
|
|
|
function stableMessage(error: unknown, fallback: string): string {
|
|
if (isPiManagedConfigError(error)) return PI_MANAGED_CONFIG_ERROR_MESSAGE;
|
|
return error instanceof PiManagementError ? error.message : fallback;
|
|
}
|
|
|
|
function smokeFailure(
|
|
message: string,
|
|
checkedAt: string,
|
|
addDiagnostic: (message: string) => void,
|
|
): PiTestResult {
|
|
addDiagnostic(message);
|
|
return { ready: false, message, checkedAt };
|
|
}
|
|
|
|
export function redact(value: string): string {
|
|
return value
|
|
.replace(/(\bauthorization\b\s*:\s*Bearer\s+)[^\s,;]+/giu, "$1[REDACTED]")
|
|
.replace(/((?:["']?)[A-Za-z0-9_-]*(?:api[_-]?key|token|password|secret|authorization)[A-Za-z0-9_-]*(?:["']?)\s*(?:=|:)\s*)(?:"[^"\r\n]*"|'[^'\r\n]*'|[^\s,;}]+)/giu, "$1[REDACTED]")
|
|
.replace(/(\bBearer\s+)[^\s,;]+/giu, "$1[REDACTED]")
|
|
.replace(/(\w+:\/\/[^:/\s]+:)[^@/\s]+@/gu, "$1[REDACTED]@");
|
|
}
|
|
|
|
function remainingBudget(deadline: number): number {
|
|
return Math.max(1, deadline - Date.now());
|
|
}
|