v0.7.12: Custom models/providers support via models.json

- Add ~/.pi/agent/models.json config for custom providers (Ollama, vLLM, etc.)
- Support all 4 API types (openai-completions, openai-responses, anthropic-messages, google-generative-ai)
- Live reload models.json on /model selector open
- Smart model defaults per provider (claude-sonnet-4-5, gpt-5.1-codex, etc.)
- Graceful session fallback when saved model missing or no API key
- Validation errors show precise file/field info in CLI and TUI
- Agent knows its own README.md path for self-documentation
- Added gpt-5.1-codex (400k context, 128k output, reasoning)

Fixes #21
This commit is contained in:
Mario Zechner 2025-11-16 22:56:24 +01:00
parent 112ce6e5d1
commit 0c5cbd0068
15 changed files with 793 additions and 114 deletions

View file

@ -1,5 +1,5 @@
import { Agent, ProviderTransport, type ThinkingLevel } from "@mariozechner/pi-agent";
import { getModel, type KnownProvider } from "@mariozechner/pi-ai";
import type { Api, KnownProvider, Model } from "@mariozechner/pi-ai";
import { ProcessTerminal, TUI } from "@mariozechner/pi-tui";
import chalk from "chalk";
import { existsSync, readFileSync } from "fs";
@ -7,6 +7,7 @@ import { homedir } from "os";
import { dirname, join, resolve } from "path";
import { fileURLToPath } from "url";
import { getChangelogPath, getNewEntries, parseChangelog } from "./changelog.js";
import { findModel, getApiKeyForModel, getAvailableModels } from "./model-config.js";
import { SessionManager } from "./session-manager.js";
import { SettingsManager } from "./settings-manager.js";
import { codingTools } from "./tools/index.js";
@ -30,6 +31,17 @@ const envApiKeyMap: Record<KnownProvider, string[]> = {
zai: ["ZAI_API_KEY"],
};
const defaultModelPerProvider: Record<KnownProvider, string> = {
anthropic: "claude-sonnet-4-5",
openai: "gpt-5.1-codex",
google: "gemini-2.5-pro",
openrouter: "openai/gpt-5.1-codex",
xai: "grok-4-fast-non-reasoning",
groq: "openai/gpt-oss-120b",
cerebras: "zai-glm-4.6",
zai: "glm-4.6",
};
type Mode = "text" | "json" | "rpc";
interface Args {
@ -189,7 +201,10 @@ function buildSystemPrompt(customPrompt?: string): string {
timeZoneName: "short",
});
let prompt = `You are an expert coding assistant. You help users with coding tasks by reading files, executing commands, editing code, and writing new files.
// Get absolute path to README.md
const readmePath = resolve(join(__dirname, "../README.md"));
let prompt = `You are actually not Claude, you are Pi. You are an expert coding assistant. You help users with coding tasks by reading files, executing commands, editing code, and writing new files.
Available tools:
- read: Read file contents
@ -203,7 +218,11 @@ Guidelines:
- Use edit for precise changes (old text must match exactly)
- Use write only for new files or complete rewrites
- Be concise in your responses
- Show file paths clearly when working with files`;
- Show file paths clearly when working with files
Documentation:
- Your own documentation (including custom model setup) is at: ${readmePath}
- Read it when users ask about features, configuration, or setup, and especially if the user asks you to add a custom model or provider.`;
// Append project context files
const contextFiles = loadProjectContextFiles();
@ -321,10 +340,12 @@ async function selectSession(sessionManager: SessionManager): Promise<string | n
async function runInteractiveMode(
agent: Agent,
sessionManager: SessionManager,
settingsManager: SettingsManager,
version: string,
changelogMarkdown: string | null = null,
modelFallbackMessage: string | null = null,
): Promise<void> {
const renderer = new TuiRenderer(agent, sessionManager, version, changelogMarkdown);
const renderer = new TuiRenderer(agent, sessionManager, settingsManager, version, changelogMarkdown);
// Initialize TUI
await renderer.init();
@ -337,6 +358,11 @@ async function runInteractiveMode(
// Render any existing messages (from --continue mode)
renderer.renderInitialMessages(agent.state);
// Show model fallback warning at the end of the chat if applicable
if (modelFallbackMessage) {
renderer.showWarning(modelFallbackMessage);
}
// Subscribe to agent events
agent.subscribe(async (event) => {
// Pass all events to the renderer
@ -449,59 +475,208 @@ export async function main(args: string[]) {
sessionManager.setSessionFile(selectedSession);
}
// Determine provider and model
const provider = (parsed.provider || "anthropic") as any;
const modelId = parsed.model || "claude-sonnet-4-5";
// Settings manager
const settingsManager = new SettingsManager();
// Helper function to get API key for a provider
const getApiKeyForProvider = (providerName: string): string | undefined => {
// Check if API key was provided via command line
if (parsed.apiKey) {
return parsed.apiKey;
// Determine initial model using priority system:
// 1. CLI args (--provider and --model)
// 2. Restored from session (if --continue or --resume)
// 3. Saved default from settings.json
// 4. First available model with valid API key
// 5. null (allowed in interactive mode)
let initialModel: Model<Api> | null = null;
if (parsed.provider && parsed.model) {
// 1. CLI args take priority
const { model, error } = findModel(parsed.provider, parsed.model);
if (error) {
console.error(chalk.red(error));
process.exit(1);
}
if (!model) {
console.error(chalk.red(`Model ${parsed.provider}/${parsed.model} not found`));
process.exit(1);
}
initialModel = model;
} else if (parsed.continue || parsed.resume) {
// 2. Restore from session (will be handled below after loading session)
// Leave initialModel as null for now
}
if (!initialModel) {
// 3. Try saved default from settings
const defaultProvider = settingsManager.getDefaultProvider();
const defaultModel = settingsManager.getDefaultModel();
if (defaultProvider && defaultModel) {
const { model, error } = findModel(defaultProvider, defaultModel);
if (error) {
console.error(chalk.red(error));
process.exit(1);
}
initialModel = model;
}
}
if (!initialModel) {
// 4. Try first available model with valid API key
// Prefer default model for each provider if available
const { models: availableModels, error } = getAvailableModels();
if (error) {
console.error(chalk.red(error));
process.exit(1);
}
const envVars = envApiKeyMap[providerName as KnownProvider];
if (availableModels.length > 0) {
// Try to find a default model from known providers
for (const provider of Object.keys(defaultModelPerProvider) as KnownProvider[]) {
const defaultModelId = defaultModelPerProvider[provider];
const match = availableModels.find((m) => m.provider === provider && m.id === defaultModelId);
if (match) {
initialModel = match;
break;
}
}
// Check each environment variable in priority order
for (const envVar of envVars) {
const key = process.env[envVar];
if (key) {
return key;
// If no default found, use first available
if (!initialModel) {
initialModel = availableModels[0];
}
}
}
return undefined;
};
// Determine mode early to know if we should print messages and fail early
const isInteractive = parsed.messages.length === 0 && parsed.mode === undefined;
const mode = parsed.mode || "text";
const shouldPrintMessages = isInteractive || mode === "text";
// Get initial API key
const initialApiKey = getApiKeyForProvider(provider);
if (!initialApiKey) {
const envVars = envApiKeyMap[provider as KnownProvider];
const envVarList = envVars.join(" or ");
console.error(chalk.red(`Error: No API key found for provider "${provider}"`));
console.error(chalk.dim(`Set ${envVarList} environment variable or use --api-key flag`));
// Non-interactive mode: fail early if no model available
if (!isInteractive && !initialModel) {
console.error(chalk.red("No models available."));
console.error(chalk.yellow("\nSet an API key environment variable:"));
console.error(" ANTHROPIC_API_KEY, OPENAI_API_KEY, GEMINI_API_KEY, etc.");
console.error(chalk.yellow("\nOr create ~/.pi/agent/models.json"));
process.exit(1);
}
// Create agent
const model = getModel(provider, modelId);
// Non-interactive mode: validate API key exists
if (!isInteractive && initialModel) {
const apiKey = parsed.apiKey || getApiKeyForModel(initialModel);
if (!apiKey) {
console.error(chalk.red(`No API key found for ${initialModel.provider}`));
process.exit(1);
}
}
const systemPrompt = buildSystemPrompt(parsed.systemPrompt);
// Load previous messages if continuing or resuming
// This may update initialModel if restoring from session
if (parsed.continue || parsed.resume) {
const messages = sessionManager.loadMessages();
if (messages.length > 0 && shouldPrintMessages) {
console.log(chalk.dim(`Loaded ${messages.length} messages from previous session`));
}
// Load and restore model (overrides initialModel if found and has API key)
const savedModel = sessionManager.loadModel();
if (savedModel) {
const { model: restoredModel, error } = findModel(savedModel.provider, savedModel.modelId);
if (error) {
console.error(chalk.red(error));
process.exit(1);
}
// Check if restored model exists and has a valid API key
const hasApiKey = restoredModel ? !!getApiKeyForModel(restoredModel) : false;
if (restoredModel && hasApiKey) {
initialModel = restoredModel;
if (shouldPrintMessages) {
console.log(chalk.dim(`Restored model: ${savedModel.provider}/${savedModel.modelId}`));
}
} else {
// Model not found or no API key - fall back to default selection
const reason = !restoredModel ? "model no longer exists" : "no API key available";
if (shouldPrintMessages) {
console.error(
chalk.yellow(
`Warning: Could not restore model ${savedModel.provider}/${savedModel.modelId} (${reason}).`,
),
);
}
// Ensure we have a valid model - use the same fallback logic
if (!initialModel) {
const { models: availableModels, error: availableError } = getAvailableModels();
if (availableError) {
console.error(chalk.red(availableError));
process.exit(1);
}
if (availableModels.length > 0) {
// Try to find a default model from known providers
for (const provider of Object.keys(defaultModelPerProvider) as KnownProvider[]) {
const defaultModelId = defaultModelPerProvider[provider];
const match = availableModels.find((m) => m.provider === provider && m.id === defaultModelId);
if (match) {
initialModel = match;
break;
}
}
// If no default found, use first available
if (!initialModel) {
initialModel = availableModels[0];
}
if (initialModel && shouldPrintMessages) {
console.log(chalk.dim(`Falling back to: ${initialModel.provider}/${initialModel.id}`));
}
} else {
// No models available at all
if (shouldPrintMessages) {
console.error(chalk.red("\nNo models available."));
console.error(chalk.yellow("Set an API key environment variable:"));
console.error(" ANTHROPIC_API_KEY, OPENAI_API_KEY, GEMINI_API_KEY, etc.");
console.error(chalk.yellow("\nOr create ~/.pi/agent/models.json"));
}
process.exit(1);
}
} else if (shouldPrintMessages) {
console.log(chalk.dim(`Falling back to: ${initialModel.provider}/${initialModel.id}`));
}
}
}
}
// Create agent (initialModel can be null in interactive mode)
const agent = new Agent({
initialState: {
systemPrompt,
model,
model: initialModel as any, // Can be null
thinkingLevel: "off",
tools: codingTools,
},
transport: new ProviderTransport({
// Dynamic API key lookup based on current model's provider
getApiKey: async () => {
const currentProvider = agent.state.model.provider;
const key = getApiKeyForProvider(currentProvider);
const currentModel = agent.state.model;
if (!currentModel) {
throw new Error("No model selected");
}
// Try CLI override first
if (parsed.apiKey) {
return parsed.apiKey;
}
// Use model-specific key lookup
const key = getApiKeyForModel(currentModel);
if (!key) {
throw new Error(
`No API key found for provider "${currentProvider}". Please set the appropriate environment variable.`,
`No API key found for provider "${currentModel.provider}". Please set the appropriate environment variable or update ~/.pi/agent/models.json`,
);
}
return key;
@ -509,41 +684,16 @@ export async function main(args: string[]) {
}),
});
// Determine mode early to know if we should print messages
const isInteractive = parsed.messages.length === 0;
const mode = parsed.mode || "text";
const shouldPrintMessages = isInteractive || mode === "text";
// Track if we had to fall back from saved model (to show in chat later)
let modelFallbackMessage: string | null = null;
// Load previous messages if continuing or resuming
if (parsed.continue || parsed.resume) {
const messages = sessionManager.loadMessages();
if (messages.length > 0) {
if (shouldPrintMessages) {
console.log(chalk.dim(`Loaded ${messages.length} messages from previous session`));
}
agent.replaceMessages(messages);
}
// Load and restore model
const savedModel = sessionManager.loadModel();
if (savedModel) {
try {
const restoredModel = getModel(savedModel.provider as any, savedModel.modelId);
agent.setModel(restoredModel);
if (shouldPrintMessages) {
console.log(chalk.dim(`Restored model: ${savedModel.provider}/${savedModel.modelId}`));
}
} catch (error: any) {
if (shouldPrintMessages) {
console.error(
chalk.yellow(
`Warning: Could not restore model ${savedModel.provider}/${savedModel.modelId}: ${error.message}`,
),
);
}
}
}
// Load and restore thinking level
const thinkingLevel = sessionManager.loadThinkingLevel() as ThinkingLevel;
if (thinkingLevel) {
@ -552,6 +702,22 @@ export async function main(args: string[]) {
console.log(chalk.dim(`Restored thinking level: ${thinkingLevel}`));
}
}
// Check if we had to fall back from saved model
const savedModel = sessionManager.loadModel();
if (savedModel && initialModel) {
const savedMatches = initialModel.provider === savedModel.provider && initialModel.id === savedModel.modelId;
if (!savedMatches) {
const { model: restoredModel, error } = findModel(savedModel.provider, savedModel.modelId);
if (error) {
// Config error - already shown above, just use generic message
modelFallbackMessage = `Could not restore model ${savedModel.provider}/${savedModel.modelId}. Using ${initialModel.provider}/${initialModel.id}.`;
} else {
const reason = !restoredModel ? "model no longer exists" : "no API key available";
modelFallbackMessage = `Could not restore model ${savedModel.provider}/${savedModel.modelId} (${reason}). Using ${initialModel.provider}/${initialModel.id}.`;
}
}
}
}
// Note: Session will be started lazily after first user+assistant message exchange
@ -589,7 +755,6 @@ export async function main(args: string[]) {
// Check if we should show changelog (only in interactive mode, only for new sessions)
let changelogMarkdown: string | null = null;
if (!parsed.continue && !parsed.resume) {
const settingsManager = new SettingsManager();
const lastVersion = settingsManager.getLastChangelogVersion();
// Check if we need to show changelog
@ -617,7 +782,14 @@ export async function main(args: string[]) {
}
// No messages and not RPC - use TUI
await runInteractiveMode(agent, sessionManager, VERSION, changelogMarkdown);
await runInteractiveMode(
agent,
sessionManager,
settingsManager,
VERSION,
changelogMarkdown,
modelFallbackMessage,
);
} else {
// CLI mode with messages
await runSingleShotMode(agent, sessionManager, parsed.messages, mode);