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

28
package-lock.json generated
View file

@ -3193,11 +3193,11 @@
},
"packages/agent": {
"name": "@mariozechner/pi-agent",
"version": "0.7.11",
"version": "0.7.12",
"license": "MIT",
"dependencies": {
"@mariozechner/pi-ai": "^0.7.10",
"@mariozechner/pi-tui": "^0.7.10"
"@mariozechner/pi-ai": "^0.7.11",
"@mariozechner/pi-tui": "^0.7.11"
},
"devDependencies": {
"@types/node": "^24.3.0",
@ -3223,7 +3223,7 @@
},
"packages/ai": {
"name": "@mariozechner/pi-ai",
"version": "0.7.11",
"version": "0.7.12",
"license": "MIT",
"dependencies": {
"@anthropic-ai/sdk": "^0.61.0",
@ -3270,11 +3270,11 @@
},
"packages/coding-agent": {
"name": "@mariozechner/pi-coding-agent",
"version": "0.7.11",
"version": "0.7.12",
"license": "MIT",
"dependencies": {
"@mariozechner/pi-agent": "^0.7.10",
"@mariozechner/pi-ai": "^0.7.10",
"@mariozechner/pi-agent": "^0.7.11",
"@mariozechner/pi-ai": "^0.7.11",
"chalk": "^5.5.0",
"diff": "^8.0.2",
"glob": "^11.0.3"
@ -3317,10 +3317,10 @@
},
"packages/pods": {
"name": "@mariozechner/pi",
"version": "0.7.11",
"version": "0.7.12",
"license": "MIT",
"dependencies": {
"@mariozechner/pi-agent": "^0.7.10",
"@mariozechner/pi-agent": "^0.7.11",
"chalk": "^5.5.0"
},
"bin": {
@ -3343,7 +3343,7 @@
},
"packages/proxy": {
"name": "@mariozechner/pi-proxy",
"version": "0.7.11",
"version": "0.7.12",
"dependencies": {
"@hono/node-server": "^1.14.0",
"hono": "^4.6.16"
@ -3359,7 +3359,7 @@
},
"packages/tui": {
"name": "@mariozechner/pi-tui",
"version": "0.7.11",
"version": "0.7.12",
"license": "MIT",
"dependencies": {
"@types/mime-types": "^2.1.4",
@ -3398,12 +3398,12 @@
},
"packages/web-ui": {
"name": "@mariozechner/pi-web-ui",
"version": "0.7.11",
"version": "0.7.12",
"license": "MIT",
"dependencies": {
"@lmstudio/sdk": "^1.5.0",
"@mariozechner/pi-ai": "^0.7.10",
"@mariozechner/pi-tui": "^0.7.10",
"@mariozechner/pi-ai": "^0.7.11",
"@mariozechner/pi-tui": "^0.7.11",
"docx-preview": "^0.3.7",
"jszip": "^3.10.1",
"lucide": "^0.544.0",

View file

@ -1,6 +1,6 @@
{
"name": "@mariozechner/pi-agent",
"version": "0.7.11",
"version": "0.7.12",
"description": "General-purpose agent with transport abstraction, state management, and attachment support",
"type": "module",
"main": "./dist/index.js",
@ -18,8 +18,8 @@
"prepublishOnly": "npm run clean && npm run build"
},
"dependencies": {
"@mariozechner/pi-ai": "^0.7.11",
"@mariozechner/pi-tui": "^0.7.11"
"@mariozechner/pi-ai": "^0.7.12",
"@mariozechner/pi-tui": "^0.7.12"
},
"keywords": [
"ai",

View file

@ -1,6 +1,6 @@
{
"name": "@mariozechner/pi-ai",
"version": "0.7.11",
"version": "0.7.12",
"description": "Unified LLM API with automatic model discovery and provider configuration",
"type": "module",
"main": "./dist/index.js",

View file

@ -2,6 +2,27 @@
## [Unreleased]
## [0.7.12] - 2025-11-16
### Added
- **Custom Models and Providers**: Support for custom models and providers via `~/.pi/agent/models.json` configuration file. Add local models (Ollama, vLLM, LM Studio) or any OpenAI-compatible, Anthropic-compatible, or Google-compatible API. File is reloaded on every `/model` selector open, allowing live updates without restart. ([#21](https://github.com/badlogic/pi-mono/issues/21))
- Added `gpt-5.1-codex` model to OpenAI provider (400k context, 128k max output, reasoning-capable).
### Changed
- **Breaking**: No longer hardcodes Anthropic/Claude as default provider/model. Now prefers sensible defaults per provider (e.g., `claude-sonnet-4-5` for Anthropic, `gpt-5.1-codex` for OpenAI), or requires explicit selection in interactive mode.
- Interactive mode now allows starting without a model, showing helpful error on message submission instead of failing at startup.
- Non-interactive mode (CLI messages, JSON, RPC) still fails early if no model or API key is available.
- Model selector now saves selected model as default in settings.json.
- `models.json` validation errors (syntax + schema) now surface with precise file/field info in both CLI and `/model` selector.
- Agent system prompt now includes absolute path to its own README.md for self-documentation.
### Fixed
- Fixed crash when restoring a session with a custom model that no longer exists or lost credentials. Now gracefully falls back to default model, logs the reason, and appends a warning message to the restored chat.
- Footer no longer crashes when no model is selected.
## [0.7.11] - 2025-11-16
### Changed

View file

@ -64,6 +64,139 @@ If no API key is set, the CLI will prompt you to configure one on first run.
**Note:** The `/model` command only shows models for which API keys are configured in your environment. If you don't see a model you expect, check that you've set the corresponding environment variable.
## Custom Models and Providers
You can add custom models and providers (like Ollama, vLLM, LM Studio, or any custom API endpoint) via `~/.pi/agent/models.json`. Supports OpenAI-compatible APIs (`openai-completions`, `openai-responses`), Anthropic Messages API (`anthropic-messages`), and Google Generative AI API (`google-generative-ai`). This file is loaded fresh every time you open the `/model` selector, allowing live updates without restarting.
### Configuration File Structure
```json
{
"providers": {
"ollama": {
"baseUrl": "http://localhost:11434/v1",
"apiKey": "OLLAMA_API_KEY",
"api": "openai-completions",
"models": [
{
"id": "llama-3.1-8b",
"name": "Llama 3.1 8B (Local)",
"reasoning": false,
"input": ["text"],
"cost": {"input": 0, "output": 0, "cacheRead": 0, "cacheWrite": 0},
"contextWindow": 128000,
"maxTokens": 32000
}
]
},
"vllm": {
"baseUrl": "http://your-server:8000/v1",
"apiKey": "VLLM_API_KEY",
"api": "openai-completions",
"models": [
{
"id": "custom-model",
"name": "Custom Fine-tuned Model",
"reasoning": false,
"input": ["text", "image"],
"cost": {"input": 0.5, "output": 1.0, "cacheRead": 0, "cacheWrite": 0},
"contextWindow": 32768,
"maxTokens": 8192
}
]
},
"mixed-api-provider": {
"baseUrl": "https://api.example.com/v1",
"apiKey": "CUSTOM_API_KEY",
"api": "openai-completions",
"models": [
{
"id": "legacy-model",
"name": "Legacy Model",
"reasoning": false,
"input": ["text"],
"cost": {"input": 1.0, "output": 2.0, "cacheRead": 0, "cacheWrite": 0},
"contextWindow": 8192,
"maxTokens": 4096
},
{
"id": "new-model",
"name": "New Model",
"api": "openai-responses",
"reasoning": true,
"input": ["text", "image"],
"cost": {"input": 0.5, "output": 1.0, "cacheRead": 0.1, "cacheWrite": 0.2},
"contextWindow": 128000,
"maxTokens": 32000
}
]
}
}
}
```
### API Key Resolution
The `apiKey` field can be either an environment variable name or a literal API key:
1. First, `pi` checks if an environment variable with that name exists
2. If found, uses the environment variable's value
3. Otherwise, treats it as a literal API key
Examples:
- `"apiKey": "OLLAMA_API_KEY"` → checks `$OLLAMA_API_KEY`, then treats as literal "OLLAMA_API_KEY"
- `"apiKey": "sk-1234..."` → checks `$sk-1234...` (unlikely to exist), then uses literal value
This allows both secure env var usage and literal keys for local servers.
### API Override
- **Provider-level `api`**: Sets the default API for all models in that provider
- **Model-level `api`**: Overrides the provider default for specific models
- Supported APIs: `openai-completions`, `openai-responses`, `anthropic-messages`, `google-generative-ai`
This is useful when a provider supports multiple API standards through the same base URL.
### Model Selection Priority
When starting `pi`, models are selected in this order:
1. **CLI args**: `--provider` and `--model` flags
2. **Restored from session**: If using `--continue` or `--resume`
3. **Saved default**: From `~/.pi/agent/settings.json` (set when you select a model with `/model`)
4. **First available**: First model with a valid API key
5. **None**: Allowed in interactive mode (shows error on message submission)
### Provider Defaults
When multiple providers are available, pi prefers sensible defaults before falling back to "first available":
| Provider | Default Model |
|------------|--------------------------|
| 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 |
### Live Reload & Errors
The models.json file is reloaded every time you open the `/model` selector. This means:
- Edit models.json during a session
- Or have the agent write/update it for you
- Use `/model` to see changes immediately
- No restart needed!
If the file contains errors (JSON syntax, schema violations, missing fields), the selector shows the exact validation error and file path in red so you can fix it immediately.
### Example: Adding Ollama Models
See the configuration structure above. Create `~/.pi/agent/models.json` with your Ollama setup, then use `/model` to select your local models. The agent can also help you write this file if you point it to this README.
## Slash Commands
The CLI supports several commands to control its behavior:
@ -273,10 +406,10 @@ pi [options] [messages...]
### Options
**--provider <name>**
Provider name. Available: `anthropic`, `openai`, `google`, `xai`, `groq`, `cerebras`, `openrouter`, `zai`. Default: `anthropic`
Provider name. Available: `anthropic`, `openai`, `google`, `xai`, `groq`, `cerebras`, `openrouter`, `zai`, plus any custom providers defined in `~/.pi/agent/models.json`.
**--model <id>**
Model ID. Default: `claude-sonnet-4-5`
Model ID. If not specified, uses: (1) saved default from settings, (2) first available model with valid API key, or (3) none (interactive mode only).
**--api-key <key>**
API key (overrides environment variables)
@ -495,7 +628,6 @@ The agent can read, update, and reference the plan as it works. Unlike ephemeral
Things that might happen eventually:
- **Custom/local models**: Support for Ollama, llama.cpp, vLLM, SGLang, LM Studio via JSON config file
- **Auto-compaction**: Currently, watch the context percentage at the bottom. When it approaches 80%, either:
- Ask the agent to write a summary .md file you can load in a new session
- Switch to a model with bigger context (e.g., Gemini) using `/model` and either continue with that model, or let it summarize the session to a .md file to be loaded in a new session

View file

@ -1,6 +1,6 @@
{
"name": "@mariozechner/pi-coding-agent",
"version": "0.7.11",
"version": "0.7.12",
"description": "Coding agent CLI with read, bash, edit, write tools and session management",
"type": "module",
"bin": {
@ -21,8 +21,8 @@
"prepublishOnly": "npm run clean && npm run build"
},
"dependencies": {
"@mariozechner/pi-agent": "^0.7.11",
"@mariozechner/pi-ai": "^0.7.11",
"@mariozechner/pi-agent": "^0.7.12",
"@mariozechner/pi-ai": "^0.7.12",
"chalk": "^5.5.0",
"diff": "^8.0.2",
"glob": "^11.0.3"

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);

View file

@ -0,0 +1,265 @@
import { type Api, getApiKey, getModels, getProviders, type KnownProvider, type Model } from "@mariozechner/pi-ai";
import { type Static, Type } from "@sinclair/typebox";
import AjvModule from "ajv";
import { existsSync, readFileSync } from "fs";
import { homedir } from "os";
import { join } from "path";
// Handle both default and named exports
const Ajv = (AjvModule as any).default || AjvModule;
// Schema for custom model definition
const ModelDefinitionSchema = Type.Object({
id: Type.String({ minLength: 1 }),
name: Type.String({ minLength: 1 }),
api: Type.Optional(
Type.Union([
Type.Literal("openai-completions"),
Type.Literal("openai-responses"),
Type.Literal("anthropic-messages"),
Type.Literal("google-generative-ai"),
]),
),
reasoning: Type.Boolean(),
input: Type.Array(Type.Union([Type.Literal("text"), Type.Literal("image")])),
cost: Type.Object({
input: Type.Number(),
output: Type.Number(),
cacheRead: Type.Number(),
cacheWrite: Type.Number(),
}),
contextWindow: Type.Number(),
maxTokens: Type.Number(),
});
const ProviderConfigSchema = Type.Object({
baseUrl: Type.String({ minLength: 1 }),
apiKey: Type.String({ minLength: 1 }),
api: Type.Optional(
Type.Union([
Type.Literal("openai-completions"),
Type.Literal("openai-responses"),
Type.Literal("anthropic-messages"),
Type.Literal("google-generative-ai"),
]),
),
models: Type.Array(ModelDefinitionSchema),
});
const ModelsConfigSchema = Type.Object({
providers: Type.Record(Type.String(), ProviderConfigSchema),
});
type ModelsConfig = Static<typeof ModelsConfigSchema>;
type ProviderConfig = Static<typeof ProviderConfigSchema>;
type ModelDefinition = Static<typeof ModelDefinitionSchema>;
// Custom provider API key mappings (provider name -> apiKey config)
const customProviderApiKeys: Map<string, string> = new Map();
/**
* Resolve an API key config value to an actual key.
* First checks if it's an environment variable, then treats as literal.
*/
export function resolveApiKey(keyConfig: string): string | undefined {
// First check if it's an env var name
const envValue = process.env[keyConfig];
if (envValue) return envValue;
// Otherwise treat as literal API key
return keyConfig;
}
/**
* Load custom models from ~/.pi/agent/models.json
* Returns { models, error } - either models array or error message
*/
function loadCustomModels(): { models: Model<Api>[]; error: string | null } {
const configPath = join(homedir(), ".pi", "agent", "models.json");
if (!existsSync(configPath)) {
return { models: [], error: null };
}
try {
const content = readFileSync(configPath, "utf-8");
const config: ModelsConfig = JSON.parse(content);
// Validate schema
const ajv = new Ajv();
const validate = ajv.compile(ModelsConfigSchema);
if (!validate(config)) {
const errors =
validate.errors?.map((e: any) => ` - ${e.instancePath || "root"}: ${e.message}`).join("\n") ||
"Unknown schema error";
return {
models: [],
error: `Invalid models.json schema:\n${errors}\n\nFile: ${configPath}`,
};
}
// Additional validation
try {
validateConfig(config);
} catch (error) {
return {
models: [],
error: `Invalid models.json: ${error instanceof Error ? error.message : error}\n\nFile: ${configPath}`,
};
}
// Parse models
return { models: parseModels(config), error: null };
} catch (error) {
if (error instanceof SyntaxError) {
return {
models: [],
error: `Failed to parse models.json: ${error.message}\n\nFile: ${configPath}`,
};
}
return {
models: [],
error: `Failed to load models.json: ${error instanceof Error ? error.message : error}\n\nFile: ${configPath}`,
};
}
}
/**
* Validate config structure and requirements
*/
function validateConfig(config: ModelsConfig): void {
for (const [providerName, providerConfig] of Object.entries(config.providers)) {
const hasProviderApi = !!providerConfig.api;
for (const modelDef of providerConfig.models) {
const hasModelApi = !!modelDef.api;
if (!hasProviderApi && !hasModelApi) {
throw new Error(
`Provider ${providerName}, model ${modelDef.id}: no "api" specified. ` +
`Set at provider or model level.`,
);
}
// Validate required fields
if (!modelDef.id) throw new Error(`Provider ${providerName}: model missing "id"`);
if (!modelDef.name) throw new Error(`Provider ${providerName}: model missing "name"`);
if (modelDef.contextWindow <= 0)
throw new Error(`Provider ${providerName}, model ${modelDef.id}: invalid contextWindow`);
if (modelDef.maxTokens <= 0)
throw new Error(`Provider ${providerName}, model ${modelDef.id}: invalid maxTokens`);
}
}
}
/**
* Parse config into Model objects
*/
function parseModels(config: ModelsConfig): Model<Api>[] {
const models: Model<Api>[] = [];
// Clear and rebuild custom provider API key mappings
customProviderApiKeys.clear();
for (const [providerName, providerConfig] of Object.entries(config.providers)) {
// Store API key config for this provider
customProviderApiKeys.set(providerName, providerConfig.apiKey);
for (const modelDef of providerConfig.models) {
// Model-level api overrides provider-level api
const api = modelDef.api || providerConfig.api;
if (!api) {
// This should have been caught by validateConfig, but be safe
continue;
}
models.push({
id: modelDef.id,
name: modelDef.name,
api: api as Api,
provider: providerName,
baseUrl: providerConfig.baseUrl,
reasoning: modelDef.reasoning,
input: modelDef.input as ("text" | "image")[],
cost: modelDef.cost,
contextWindow: modelDef.contextWindow,
maxTokens: modelDef.maxTokens,
});
}
}
return models;
}
/**
* Get all models (built-in + custom), freshly loaded
* Returns { models, error } - either models array or error message
*/
export function loadAndMergeModels(): { models: Model<Api>[]; error: string | null } {
const builtInModels: Model<Api>[] = [];
const providers = getProviders();
// Load all built-in models
for (const provider of providers) {
const providerModels = getModels(provider as KnownProvider);
builtInModels.push(...(providerModels as Model<Api>[]));
}
// Load custom models
const { models: customModels, error } = loadCustomModels();
if (error) {
return { models: [], error };
}
// Merge: custom models come after built-in
return { models: [...builtInModels, ...customModels], error: null };
}
/**
* Get API key for a model (checks custom providers first, then built-in)
*/
export function getApiKeyForModel(model: Model<Api>): string | undefined {
// For custom providers, check their apiKey config
const customKeyConfig = customProviderApiKeys.get(model.provider);
if (customKeyConfig) {
return resolveApiKey(customKeyConfig);
}
// For built-in providers, use getApiKey from @mariozechner/pi-ai
return getApiKey(model.provider as KnownProvider);
}
/**
* Get only models that have valid API keys available
* Returns { models, error } - either models array or error message
*/
export function getAvailableModels(): { models: Model<Api>[]; error: string | null } {
const { models: allModels, error } = loadAndMergeModels();
if (error) {
return { models: [], error };
}
const availableModels = allModels.filter((model) => {
const apiKey = getApiKeyForModel(model);
return !!apiKey;
});
return { models: availableModels, error: null };
}
/**
* Find a specific model by provider and ID
* Returns { model, error } - either model or error message
*/
export function findModel(provider: string, modelId: string): { model: Model<Api> | null; error: string | null } {
const { models: allModels, error } = loadAndMergeModels();
if (error) {
return { model: null, error };
}
const model = allModels.find((m) => m.provider === provider && m.id === modelId) || null;
return { model, error: null };
}

View file

@ -4,6 +4,8 @@ import { dirname, join } from "path";
export interface Settings {
lastChangelogVersion?: string;
defaultProvider?: string;
defaultModel?: string;
}
export class SettingsManager {
@ -52,4 +54,28 @@ export class SettingsManager {
this.settings.lastChangelogVersion = version;
this.save();
}
getDefaultProvider(): string | undefined {
return this.settings.defaultProvider;
}
getDefaultModel(): string | undefined {
return this.settings.defaultModel;
}
setDefaultProvider(provider: string): void {
this.settings.defaultProvider = provider;
this.save();
}
setDefaultModel(modelId: string): void {
this.settings.defaultModel = modelId;
this.save();
}
setDefaultModelAndProvider(provider: string, modelId: string): void {
this.settings.defaultProvider = provider;
this.settings.defaultModel = modelId;
this.save();
}
}

View file

@ -1,6 +1,8 @@
import { getApiKey, getModels, getProviders, type Model } from "@mariozechner/pi-ai";
import type { Model } from "@mariozechner/pi-ai";
import { Container, Input, Spacer, Text } from "@mariozechner/pi-tui";
import chalk from "chalk";
import { getAvailableModels } from "../model-config.js";
import type { SettingsManager } from "../settings-manager.js";
interface ModelItem {
provider: string;
@ -17,18 +19,26 @@ export class ModelSelectorComponent extends Container {
private allModels: ModelItem[] = [];
private filteredModels: ModelItem[] = [];
private selectedIndex: number = 0;
private currentModel: Model<any>;
private currentModel: Model<any> | null;
private settingsManager: SettingsManager;
private onSelectCallback: (model: Model<any>) => void;
private onCancelCallback: () => void;
private errorMessage: string | null = null;
constructor(currentModel: Model<any>, onSelect: (model: Model<any>) => void, onCancel: () => void) {
constructor(
currentModel: Model<any> | null,
settingsManager: SettingsManager,
onSelect: (model: Model<any>) => void,
onCancel: () => void,
) {
super();
this.currentModel = currentModel;
this.settingsManager = settingsManager;
this.onSelectCallback = onSelect;
this.onCancelCallback = onCancel;
// Load all models
// Load all models (fresh every time)
this.loadModels();
// Add top border
@ -67,30 +77,35 @@ export class ModelSelectorComponent extends Container {
}
private loadModels(): void {
const models: ModelItem[] = [];
const providers = getProviders();
// Load available models fresh (includes custom models from ~/.pi/agent/models.json)
const { models: availableModels, error } = getAvailableModels();
for (const provider of providers) {
const providerModels = getModels(provider as any);
for (const model of providerModels) {
models.push({ provider, id: model.id, model });
}
// If there's an error loading models.json, we'll show it via the "no models" path
// The error will be displayed to the user
if (error) {
this.allModels = [];
this.filteredModels = [];
this.errorMessage = error;
return;
}
// Filter out models from providers without API keys
const filteredModels = models.filter((item) => getApiKey(item.provider as any) !== undefined);
const models: ModelItem[] = availableModels.map((model) => ({
provider: model.provider,
id: model.id,
model,
}));
// Sort: current model first, then by provider
filteredModels.sort((a, b) => {
const aIsCurrent = this.currentModel?.id === a.model.id;
const bIsCurrent = this.currentModel?.id === b.model.id;
models.sort((a, b) => {
const aIsCurrent = this.currentModel?.id === a.model.id && this.currentModel?.provider === a.provider;
const bIsCurrent = this.currentModel?.id === b.model.id && this.currentModel?.provider === b.provider;
if (aIsCurrent && !bIsCurrent) return -1;
if (!aIsCurrent && bIsCurrent) return 1;
return a.provider.localeCompare(b.provider);
});
this.allModels = filteredModels;
this.filteredModels = filteredModels;
this.allModels = models;
this.filteredModels = models;
}
private filterModels(query: string): void {
@ -152,8 +167,14 @@ export class ModelSelectorComponent extends Container {
this.listContainer.addChild(new Text(scrollInfo, 0, 0));
}
// Show "no results" if empty
if (this.filteredModels.length === 0) {
// Show error message or "no results" if empty
if (this.errorMessage) {
// Show error in red
const errorLines = this.errorMessage.split("\n");
for (const line of errorLines) {
this.listContainer.addChild(new Text(chalk.red(line), 0, 0));
}
} else if (this.filteredModels.length === 0) {
this.listContainer.addChild(new Text(chalk.gray(" No matching models"), 0, 0));
}
}
@ -188,6 +209,8 @@ export class ModelSelectorComponent extends Container {
}
private handleSelect(model: Model<any>): void {
// Save as new default
this.settingsManager.setDefaultModelAndProvider(model.provider, model.id);
this.onSelectCallback(model);
}

View file

@ -14,7 +14,9 @@ import {
import chalk from "chalk";
import { getChangelogPath, parseChangelog } from "../changelog.js";
import { exportSessionToHtml } from "../export-html.js";
import { getApiKeyForModel } from "../model-config.js";
import type { SessionManager } from "../session-manager.js";
import type { SettingsManager } from "../settings-manager.js";
import { AssistantMessageComponent } from "./assistant-message.js";
import { CustomEditor } from "./custom-editor.js";
import { DynamicBorder } from "./dynamic-border.js";
@ -37,6 +39,7 @@ export class TuiRenderer {
private footer: FooterComponent;
private agent: Agent;
private sessionManager: SessionManager;
private settingsManager: SettingsManager;
private version: string;
private isInitialized = false;
private onInputCallback?: (text: string) => void;
@ -63,9 +66,16 @@ export class TuiRenderer {
// Track if this is the first user message (to skip spacer)
private isFirstUserMessage = true;
constructor(agent: Agent, sessionManager: SessionManager, version: string, changelogMarkdown: string | null = null) {
constructor(
agent: Agent,
sessionManager: SessionManager,
settingsManager: SettingsManager,
version: string,
changelogMarkdown: string | null = null,
) {
this.agent = agent;
this.sessionManager = sessionManager;
this.settingsManager = settingsManager;
this.version = version;
this.changelogMarkdown = changelogMarkdown;
this.ui = new TUI(new ProcessTerminal());
@ -223,6 +233,28 @@ export class TuiRenderer {
return;
}
// Normal message submission - validate model and API key first
const currentModel = this.agent.state.model;
if (!currentModel) {
this.showError(
"No model selected.\n\n" +
"Set an API key (ANTHROPIC_API_KEY, OPENAI_API_KEY, etc.)\n" +
"or create ~/.pi/agent/models.json\n\n" +
"Then use /model to select a model.",
);
return;
}
const apiKey = getApiKeyForModel(currentModel);
if (!apiKey) {
this.showError(
`No API key found for ${currentModel.provider}.\n\n` +
`Set the appropriate environment variable or update ~/.pi/agent/models.json`,
);
return;
}
// All good, proceed with submission
if (this.onInputCallback) {
this.onInputCallback(text);
}
@ -498,6 +530,13 @@ export class TuiRenderer {
this.ui.requestRender();
}
showWarning(warningMessage: string): void {
// Show warning message in the chat
this.chatContainer.addChild(new Spacer(1));
this.chatContainer.addChild(new Text(chalk.yellow(`Warning: ${warningMessage}`), 1, 0));
this.ui.requestRender();
}
private showThinkingSelector(): void {
// Create thinking selector with current level
this.thinkingSelector = new ThinkingSelectorComponent(
@ -544,6 +583,7 @@ export class TuiRenderer {
// Create model selector with current model
this.modelSelector = new ModelSelectorComponent(
this.agent.state.model,
this.settingsManager,
(model) => {
// Apply the selected model
this.agent.setModel(model);

View file

@ -1,6 +1,6 @@
{
"name": "@mariozechner/pi",
"version": "0.7.11",
"version": "0.7.12",
"description": "CLI tool for managing vLLM deployments on GPU pods",
"type": "module",
"bin": {
@ -34,7 +34,7 @@
"node": ">=20.0.0"
},
"dependencies": {
"@mariozechner/pi-agent": "^0.7.11",
"@mariozechner/pi-agent": "^0.7.12",
"chalk": "^5.5.0"
},
"devDependencies": {}

View file

@ -1,6 +1,6 @@
{
"name": "@mariozechner/pi-proxy",
"version": "0.7.11",
"version": "0.7.12",
"type": "module",
"description": "CORS and authentication proxy for pi-ai",
"main": "dist/index.js",

View file

@ -1,6 +1,6 @@
{
"name": "@mariozechner/pi-tui",
"version": "0.7.11",
"version": "0.7.12",
"description": "Terminal User Interface library with differential rendering for efficient text-based applications",
"type": "module",
"main": "dist/index.js",

View file

@ -1,6 +1,6 @@
{
"name": "@mariozechner/pi-web-ui",
"version": "0.7.11",
"version": "0.7.12",
"description": "Reusable web UI components for AI chat interfaces powered by @mariozechner/pi-ai",
"type": "module",
"main": "dist/index.js",
@ -18,8 +18,8 @@
},
"dependencies": {
"@lmstudio/sdk": "^1.5.0",
"@mariozechner/pi-ai": "^0.7.11",
"@mariozechner/pi-tui": "^0.7.11",
"@mariozechner/pi-ai": "^0.7.12",
"@mariozechner/pi-tui": "^0.7.12",
"docx-preview": "^0.3.7",
"jszip": "^3.10.1",
"lucide": "^0.544.0",