mirror of
https://github.com/getcompanion-ai/co-mono.git
synced 2026-04-15 18:01:22 +00:00
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:
parent
112ce6e5d1
commit
0c5cbd0068
15 changed files with 793 additions and 114 deletions
28
package-lock.json
generated
28
package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
265
packages/coding-agent/src/model-config.ts
Normal file
265
packages/coding-agent/src/model-config.ts
Normal 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 };
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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": {}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue