import { execSync } from "node:child_process"; import { existsSync, readFileSync } from "node:fs"; import { dirname, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import { getModel, type Api, type Model } from "@mariozechner/pi-ai"; import type { BotAppConfig, ThinkingLevel } from "./core.js"; type ModelInput = "text" | "image"; interface ModelCost { input: number; output: number; cacheRead: number; cacheWrite: number; } interface ModelOverride { name?: string; reasoning?: boolean; input?: ModelInput[]; cost?: Partial; contextWindow?: number; maxTokens?: number; headers?: Record; compat?: Model["compat"]; } interface ProviderModelConfig { id: string; name?: string; api?: Api; baseUrl?: string; reasoning?: boolean; input?: ModelInput[]; cost?: ModelCost; contextWindow?: number; maxTokens?: number; headers?: Record; compat?: Model["compat"]; } export interface ProviderConfig { api?: Api; apiKey?: string; baseUrl?: string; headers?: Record; compat?: Model["compat"]; authHeader?: boolean; modelOverrides?: Record; models?: ProviderModelConfig[]; } export interface Config { agents?: { defaults?: { model?: { primary?: string; }; thinkingLevel?: ThinkingLevel; workspace?: string; }; }; models?: { providers?: Record; }; channels?: { feishu?: { enabled?: boolean; appId?: string; appSecret?: string; domain?: string; encryptKey?: string; verificationToken?: string; requireMention?: boolean; thinkingReaction?: { enabled?: boolean; emojiType?: string; }; }; wechat?: { enabled?: boolean; requireMention?: boolean; }; }; } export interface ModelReference { provider: string; modelId: string; } export interface LoadedConfig { path: string; directory: string; workspaceDir: string; source: "file" | "fallback-mock"; thinkingLevel?: ThinkingLevel; config: Config; providers: Record; defaultModel?: ModelReference; } export interface ResolvedRuntimeModel { model: Model; apiKey?: string; } export interface ResolveRuntimeModelOptions { provider?: string; model?: string; baseUrl?: string; configPath?: string; env?: NodeJS.ProcessEnv; } export interface LoadBotAppConfigOptions { configPath?: string; env?: NodeJS.ProcessEnv; } const DEFAULT_COST: ModelCost = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }; const DEFAULT_APP_NAME = "starter-bot"; const DEFAULT_CONFIG_FILENAME = "config.json"; const MODULE_DIR = dirname(fileURLToPath(import.meta.url)); function createMockConfig(): Config { return { agents: { defaults: { workspace: ".", model: { primary: "openai/gpt-5-mini" } } }, channels: { feishu: { enabled: false, requireMention: true, thinkingReaction: { enabled: true, emojiType: "OneSecond" } }, wechat: { enabled: false, requireMention: false } } }; } function createLoadedConfig( configPath: string, parsed: Config, source: LoadedConfig["source"] ): LoadedConfig { const directory = dirname(configPath); const workspaceDir = resolve(directory, parsed.agents?.defaults?.workspace ?? "."); const defaultModelReference = parsed.agents?.defaults?.model?.primary; return { path: configPath, directory, workspaceDir, source, thinkingLevel: parsed.agents?.defaults?.thinkingLevel, config: parsed, providers: parsed.models?.providers ?? {}, defaultModel: defaultModelReference ? parseModelReference(defaultModelReference, "agents.defaults.model.primary") : undefined }; } export function parseModelReference(reference: string, description = "model reference"): ModelReference { const normalized = reference.trim(); const splitIndex = normalized.indexOf("/"); if (splitIndex <= 0 || splitIndex === normalized.length - 1) { throw new Error( `${description} must use "provider/model-id" format, received "${reference}".` ); } return { provider: normalized.slice(0, splitIndex), modelId: normalized.slice(splitIndex + 1) }; } export function loadConfig(configPath: string): LoadedConfig { if (!existsSync(configPath)) { console.warn(`[pi-bot] Config not found: ${configPath}. Falling back to mock config.`); return createLoadedConfig(configPath, createMockConfig(), "fallback-mock"); } let parsed: Config; try { parsed = JSON.parse(readFileSync(configPath, "utf-8")) as Config; } catch (error) { throw new Error( `Failed to read config at ${configPath}: ${ error instanceof Error ? error.message : String(error) }` ); } return createLoadedConfig(configPath, parsed, "file"); } export function loadOptionalConfig(configPath: string | undefined): LoadedConfig | undefined { if (!configPath) { return undefined; } return loadConfig(configPath); } export function resolveRuntimeModel( options: ResolveRuntimeModelOptions ): ResolvedRuntimeModel | undefined { const loadedConfig = loadOptionalConfig(options.configPath); const selection = resolveModelSelection(options, loadedConfig); if (!selection) { return undefined; } const providerConfig = loadedConfig?.providers[selection.provider]; if (providerConfig) { return createConfiguredProviderModel({ provider: selection.provider, modelId: selection.modelId, providerConfig, explicitBaseUrl: options.baseUrl, configPath: loadedConfig.path, env: options.env }); } const builtInModel = getModel(selection.provider as never, selection.modelId); if (!builtInModel) { throw new Error( `Unknown provider/model "${selection.provider}/${selection.modelId}". ` + `It was not found in config.json or built-in pi-ai models.` ); } return { model: applyBaseUrlOverride(cloneModel(builtInModel), options.baseUrl) }; } function resolveModelSelection( options: ResolveRuntimeModelOptions, loadedConfig: LoadedConfig | undefined ): ModelReference | undefined { if (options.provider && options.model) { return { provider: options.provider, modelId: options.model }; } if (!options.provider && options.model?.includes("/")) { return parseModelReference(options.model, "agent.model"); } return loadedConfig?.defaultModel; } export function getProjectRoot(): string { return resolve(MODULE_DIR, ".."); } export function getDefaultConfigPath(): string { const projectRoot = getProjectRoot(); return resolve(projectRoot, "<%= workspaceDir %>", DEFAULT_CONFIG_FILENAME); } export function loadBotAppConfig( options: LoadBotAppConfigOptions = {} ): BotAppConfig { const env = options.env ?? process.env; const configPath = options.configPath ?? getDefaultConfigPath(); const loadedConfig = loadConfig(configPath); const workspaceDir = loadedConfig.workspaceDir; const defaultModel = loadedConfig.defaultModel; const feishuCfg = loadedConfig.config.channels?.feishu; const wechatCfg = loadedConfig.config.channels?.wechat; return { appName: DEFAULT_APP_NAME, configRoot: loadedConfig.config as Record, agent: { mode: (env.PI_BOT_AGENT_MODE as BotAppConfig["agent"]["mode"] | undefined) ?? (loadedConfig.source === "fallback-mock" ? "mock" : "pi"), provider: defaultModel?.provider ?? "openai", model: defaultModel?.modelId ?? "gpt-5-mini", configPath: loadedConfig.source === "file" ? configPath : undefined, thinkingLevel: loadedConfig.thinkingLevel, cwd: workspaceDir, agentDir: workspaceDir }, routing: { feishuGroupRequireMention: feishuCfg?.requireMention ?? true, wechatGroupRequireMention: wechatCfg?.requireMention ?? false }, channels: { feishu: { enabled: feishuCfg?.enabled ?? false, appId: resolveOptionalRuntimeValue(feishuCfg?.appId, "channels.feishu.appId", env), appSecret: resolveOptionalRuntimeValue(feishuCfg?.appSecret, "channels.feishu.appSecret", env), domain: resolveOptionalRuntimeValue(feishuCfg?.domain, "channels.feishu.domain", env), encryptKey: resolveOptionalRuntimeValue(feishuCfg?.encryptKey, "channels.feishu.encryptKey", env), verificationToken: resolveOptionalRuntimeValue(feishuCfg?.verificationToken, "channels.feishu.verificationToken", env), thinkingReaction: { enabled: feishuCfg?.thinkingReaction?.enabled ?? true, emojiType: feishuCfg?.thinkingReaction?.emojiType } }, wechat: { enabled: wechatCfg?.enabled ?? false } } }; } function createConfiguredProviderModel(options: { provider: string; modelId: string; providerConfig: ProviderConfig; explicitBaseUrl?: string; configPath: string; env?: NodeJS.ProcessEnv; }): ResolvedRuntimeModel { const explicitBaseUrl = options.explicitBaseUrl ? resolveRuntimeValue( options.explicitBaseUrl, `${options.provider}/${options.modelId} baseUrl override`, options.env ) : undefined; const apiKey = options.providerConfig.apiKey ? resolveRuntimeValue( options.providerConfig.apiKey, `${options.provider} apiKey in ${options.configPath}`, options.env ) : undefined; const providerHeaders = resolveHeaders( options.providerConfig.headers, `${options.provider} headers in ${options.configPath}`, options.env ); const override = options.providerConfig.modelOverrides?.[options.modelId]; const customModel = options.providerConfig.models?.find((model) => model.id === options.modelId); let model: Model; if (customModel) { const api = customModel.api ?? options.providerConfig.api; const baseUrl = explicitBaseUrl ?? resolveOptionalRuntimeValue( customModel.baseUrl ?? options.providerConfig.baseUrl, `${options.provider}/${options.modelId} baseUrl in ${options.configPath}`, options.env ); if (!api) { throw new Error( `Provider "${options.provider}" model "${options.modelId}" is missing an api field in ${options.configPath}.` ); } if (!baseUrl) { throw new Error( `Provider "${options.provider}" model "${options.modelId}" is missing a baseUrl in ${options.configPath}.` ); } model = { id: customModel.id, name: customModel.name ?? customModel.id, api, provider: options.provider, baseUrl, reasoning: customModel.reasoning ?? false, input: [...(customModel.input ?? ["text"])], cost: { ...(customModel.cost ?? DEFAULT_COST) }, contextWindow: customModel.contextWindow ?? 128000, maxTokens: customModel.maxTokens ?? 16384, headers: mergeHeaders( providerHeaders, resolveHeaders( customModel.headers, `${options.provider}/${options.modelId} headers in ${options.configPath}`, options.env ) ), compat: mergeCompat(options.providerConfig.compat, customModel.compat) }; } else { const builtInModel = getModel(options.provider as never, options.modelId); if (!builtInModel) { const availableModels = new Set(); for (const configuredModel of options.providerConfig.models ?? []) { availableModels.add(configuredModel.id); } throw new Error( `Unknown model "${options.provider}/${options.modelId}" in ${options.configPath}. ` + `Configured models: ${Array.from(availableModels).sort().join(", ") || "(none)"}` ); } model = cloneModel(builtInModel); model.baseUrl = explicitBaseUrl ?? resolveOptionalRuntimeValue( options.providerConfig.baseUrl, `${options.provider} baseUrl in ${options.configPath}`, options.env ) ?? model.baseUrl; model.headers = mergeHeaders(model.headers, providerHeaders); model.compat = mergeCompat(model.compat, options.providerConfig.compat); } model = applyModelOverride( model, override, `${options.provider}/${options.modelId} override in ${options.configPath}`, options.env ); if (options.providerConfig.authHeader && apiKey) { model.headers = mergeHeaders(model.headers, { Authorization: `Bearer ${apiKey}` }); return { model }; } return { model, apiKey }; } function cloneModel(model: Model): Model { return { ...model, input: [...model.input], cost: { ...model.cost }, headers: model.headers ? { ...model.headers } : undefined, compat: model.compat ? mergeCompat(model.compat) : undefined }; } function applyBaseUrlOverride(model: Model, baseUrl: string | undefined): Model { if (!baseUrl) { return model; } return { ...model, baseUrl }; } function applyModelOverride( model: Model, override: ModelOverride | undefined, description: string, env: NodeJS.ProcessEnv = process.env ): Model { if (!override) { return model; } const nextModel = cloneModel(model); if (override.name !== undefined) nextModel.name = override.name; if (override.reasoning !== undefined) nextModel.reasoning = override.reasoning; if (override.input !== undefined) nextModel.input = [...override.input]; if (override.contextWindow !== undefined) nextModel.contextWindow = override.contextWindow; if (override.maxTokens !== undefined) nextModel.maxTokens = override.maxTokens; if (override.cost) { nextModel.cost = { input: override.cost.input ?? nextModel.cost.input, output: override.cost.output ?? nextModel.cost.output, cacheRead: override.cost.cacheRead ?? nextModel.cost.cacheRead, cacheWrite: override.cost.cacheWrite ?? nextModel.cost.cacheWrite }; } nextModel.headers = mergeHeaders( nextModel.headers, resolveHeaders(override.headers, `${description} headers`, env) ); nextModel.compat = mergeCompat(nextModel.compat, override.compat); return nextModel; } function mergeCompat( base?: Model["compat"], override?: Model["compat"] ): Model["compat"] | undefined { if (!base && !override) { return undefined; } const merged = { ...(base ?? {}), ...(override ?? {}) } as Record; const baseRecord = (base ?? {}) as Record; const overrideRecord = (override ?? {}) as Record; if (isPlainObject(baseRecord.openRouterRouting) || isPlainObject(overrideRecord.openRouterRouting)) { merged.openRouterRouting = { ...(isPlainObject(baseRecord.openRouterRouting) ? baseRecord.openRouterRouting : {}), ...(isPlainObject(overrideRecord.openRouterRouting) ? overrideRecord.openRouterRouting : {}) }; } if (isPlainObject(baseRecord.vercelGatewayRouting) || isPlainObject(overrideRecord.vercelGatewayRouting)) { merged.vercelGatewayRouting = { ...(isPlainObject(baseRecord.vercelGatewayRouting) ? baseRecord.vercelGatewayRouting : {}), ...(isPlainObject(overrideRecord.vercelGatewayRouting) ? overrideRecord.vercelGatewayRouting : {}) }; } if (isPlainObject(baseRecord.reasoningEffortMap) || isPlainObject(overrideRecord.reasoningEffortMap)) { merged.reasoningEffortMap = { ...(isPlainObject(baseRecord.reasoningEffortMap) ? baseRecord.reasoningEffortMap : {}), ...(isPlainObject(overrideRecord.reasoningEffortMap) ? overrideRecord.reasoningEffortMap : {}) }; } return merged as Model["compat"]; } function mergeHeaders( ...headersList: Array | undefined> ): Record | undefined { const merged = headersList.reduce>((acc, headers) => { if (!headers) { return acc; } return { ...acc, ...headers }; }, {}); return Object.keys(merged).length > 0 ? merged : undefined; } function resolveHeaders( headers: Record | undefined, description: string, env: NodeJS.ProcessEnv = process.env ): Record | undefined { if (!headers) { return undefined; } const resolvedHeaders = Object.fromEntries( Object.entries(headers).map(([key, value]) => [ key, resolveRuntimeValue(value, `${description} header "${key}"`, env) ]) ); return Object.keys(resolvedHeaders).length > 0 ? resolvedHeaders : undefined; } function resolveOptionalRuntimeValue( value: string | undefined, description: string, env: NodeJS.ProcessEnv = process.env ): string | undefined { return value ? resolveRuntimeValue(value, description, env) : undefined; } function resolveRuntimeValue( value: string, description: string, env: NodeJS.ProcessEnv = process.env ): string { if (value.startsWith("!")) { try { const output = execSync(value.slice(1), { encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"] }).trim(); if (!output) { throw new Error("command returned empty output"); } return output; } catch (error) { throw new Error( `Failed to resolve ${description} from command "${value.slice(1)}": ${ error instanceof Error ? error.message : String(error) }` ); } } return value.replace(/\$\{([A-Z0-9_]+)\}/g, (_, variableName: string) => { const resolved = env[variableName]; if (!resolved) { throw new Error(`Missing environment variable ${variableName} while resolving ${description}.`); } return resolved; }); } function isPlainObject(value: unknown): value is Record { return Boolean(value) && typeof value === "object" && !Array.isArray(value); } export function ensureObject(value: unknown): Record { if (value && typeof value === "object" && !Array.isArray(value)) { return value as Record; } return {}; } export function readString(value: unknown): string { return typeof value === "string" ? value : ""; } function isArrayIndex(segment: string): boolean { return /^\d+$/.test(segment); } export function getNested(root: Record, path: string[]): unknown { let current: unknown = root; for (const segment of path) { if (current === null || current === undefined) return undefined; if (Array.isArray(current)) { if (!isArrayIndex(segment)) return undefined; current = current[Number(segment)]; } else if (typeof current === "object") { current = (current as Record)[segment]; } else { return undefined; } } return current; } export function setNested(root: Record, path: string[], value: unknown): void { let current: unknown = root; for (let i = 0; i < path.length - 1; i++) { const segment = path[i]!; const nextSegment = path[i + 1]!; if (Array.isArray(current)) { if (!isArrayIndex(segment)) { throw new Error(`Invalid array index "${segment}" in path "${path.join(".")}"`); } const index = Number(segment); if (current[index] === undefined || current[index] === null || typeof current[index] !== "object") { current[index] = isArrayIndex(nextSegment) ? [] : {}; } current = current[index]; } else if (current && typeof current === "object") { const obj = current as Record; if (obj[segment] === undefined || obj[segment] === null || typeof obj[segment] !== "object") { obj[segment] = isArrayIndex(nextSegment) ? [] : {}; } current = obj[segment]; } } const lastSegment = path[path.length - 1]!; if (Array.isArray(current)) { if (!isArrayIndex(lastSegment)) { throw new Error(`Invalid array index "${lastSegment}" in path "${path.join(".")}"`); } current[Number(lastSegment)] = value; } else if (current && typeof current === "object") { (current as Record)[lastSegment] = value; } } export function deleteNested(root: Record, path: string[]): boolean { let current: unknown = root; for (let i = 0; i < path.length - 1; i++) { const segment = path[i]!; if (Array.isArray(current)) { if (!isArrayIndex(segment)) return false; current = current[Number(segment)]; } else if (current && typeof current === "object") { current = (current as Record)[segment]; } else { return false; } if (current === null || current === undefined) return false; } const lastSegment = path[path.length - 1]!; if (Array.isArray(current)) { if (!isArrayIndex(lastSegment)) return false; const index = Number(lastSegment); if (index >= current.length) return false; current.splice(index, 1); return true; } if (current && typeof current === "object") { const obj = current as Record; if (lastSegment in obj) { delete obj[lastSegment]; return true; } } return false; } export function updateString(root: Record, path: string[], value: unknown): void { if (value === undefined) return; if (typeof value === "string" && value.trim() === "") { deleteNested(root, path); return; } setNested(root, path, value); } export function updateBool(root: Record, path: string[], value: unknown): void { if (value === undefined) return; setNested(root, path, Boolean(value)); }