Polish Feynman harness and stabilize Pi web runtime
This commit is contained in:
282
src/model/catalog.ts
Normal file
282
src/model/catalog.ts
Normal file
@@ -0,0 +1,282 @@
|
||||
import { AuthStorage, ModelRegistry } from "@mariozechner/pi-coding-agent";
|
||||
|
||||
type ModelRecord = {
|
||||
provider: string;
|
||||
id: string;
|
||||
name?: string;
|
||||
};
|
||||
|
||||
export type ProviderStatus = {
|
||||
id: string;
|
||||
label: string;
|
||||
supportedModels: number;
|
||||
availableModels: number;
|
||||
configured: boolean;
|
||||
current: boolean;
|
||||
recommended: boolean;
|
||||
};
|
||||
|
||||
export type ModelStatusSnapshot = {
|
||||
current?: string;
|
||||
currentValid: boolean;
|
||||
recommended?: string;
|
||||
recommendationReason?: string;
|
||||
availableModels: string[];
|
||||
providers: ProviderStatus[];
|
||||
guidance: string[];
|
||||
};
|
||||
|
||||
const PROVIDER_LABELS: Record<string, string> = {
|
||||
anthropic: "Anthropic",
|
||||
openai: "OpenAI",
|
||||
"openai-codex": "OpenAI Codex",
|
||||
openrouter: "OpenRouter",
|
||||
google: "Google",
|
||||
"google-gemini-cli": "Google Gemini CLI",
|
||||
zai: "Z.AI / GLM",
|
||||
minimax: "MiniMax",
|
||||
"minimax-cn": "MiniMax (China)",
|
||||
"github-copilot": "GitHub Copilot",
|
||||
"vercel-ai-gateway": "Vercel AI Gateway",
|
||||
opencode: "OpenCode",
|
||||
"opencode-go": "OpenCode Go",
|
||||
"kimi-coding": "Kimi / Moonshot",
|
||||
xai: "xAI",
|
||||
groq: "Groq",
|
||||
mistral: "Mistral",
|
||||
cerebras: "Cerebras",
|
||||
huggingface: "Hugging Face",
|
||||
"amazon-bedrock": "Amazon Bedrock",
|
||||
"azure-openai-responses": "Azure OpenAI Responses",
|
||||
};
|
||||
|
||||
const RESEARCH_MODEL_PREFERENCES = [
|
||||
{
|
||||
spec: "anthropic/claude-opus-4-6",
|
||||
reason: "strong long-context reasoning for source-heavy research work",
|
||||
},
|
||||
{
|
||||
spec: "anthropic/claude-opus-4-5",
|
||||
reason: "strong long-context reasoning for source-heavy research work",
|
||||
},
|
||||
{
|
||||
spec: "anthropic/claude-sonnet-4-6",
|
||||
reason: "balanced reasoning and speed for iterative research sessions",
|
||||
},
|
||||
{
|
||||
spec: "anthropic/claude-sonnet-4-5",
|
||||
reason: "balanced reasoning and speed for iterative research sessions",
|
||||
},
|
||||
{
|
||||
spec: "openai/gpt-5.4",
|
||||
reason: "strong general reasoning and drafting quality for research tasks",
|
||||
},
|
||||
{
|
||||
spec: "openai/gpt-5",
|
||||
reason: "strong general reasoning and drafting quality for research tasks",
|
||||
},
|
||||
{
|
||||
spec: "openai-codex/gpt-5.4",
|
||||
reason: "strong research + coding balance when Pi exposes Codex directly",
|
||||
},
|
||||
{
|
||||
spec: "google/gemini-3-pro-preview",
|
||||
reason: "good fallback for broad web-and-doc research work",
|
||||
},
|
||||
{
|
||||
spec: "google/gemini-2.5-pro",
|
||||
reason: "good fallback for broad web-and-doc research work",
|
||||
},
|
||||
{
|
||||
spec: "openrouter/openai/gpt-5.1-codex",
|
||||
reason: "good routed fallback when only OpenRouter is configured",
|
||||
},
|
||||
{
|
||||
spec: "zai/glm-5",
|
||||
reason: "good fallback when GLM is the available research model",
|
||||
},
|
||||
{
|
||||
spec: "kimi-coding/kimi-k2-thinking",
|
||||
reason: "good fallback when Kimi is the available research model",
|
||||
},
|
||||
];
|
||||
|
||||
const PROVIDER_SORT_ORDER = [
|
||||
"anthropic",
|
||||
"openai",
|
||||
"openai-codex",
|
||||
"google",
|
||||
"openrouter",
|
||||
"zai",
|
||||
"kimi-coding",
|
||||
"minimax",
|
||||
"minimax-cn",
|
||||
"github-copilot",
|
||||
"vercel-ai-gateway",
|
||||
];
|
||||
|
||||
function formatProviderLabel(provider: string): string {
|
||||
return PROVIDER_LABELS[provider] ?? provider;
|
||||
}
|
||||
|
||||
function modelSpec(model: ModelRecord): string {
|
||||
return `${model.provider}/${model.id}`;
|
||||
}
|
||||
|
||||
function compareByResearchPreference(left: ModelRecord, right: ModelRecord): number {
|
||||
const leftSpec = modelSpec(left);
|
||||
const rightSpec = modelSpec(right);
|
||||
const leftIndex = RESEARCH_MODEL_PREFERENCES.findIndex((entry) => entry.spec === leftSpec);
|
||||
const rightIndex = RESEARCH_MODEL_PREFERENCES.findIndex((entry) => entry.spec === rightSpec);
|
||||
|
||||
if (leftIndex !== -1 || rightIndex !== -1) {
|
||||
if (leftIndex === -1) return 1;
|
||||
if (rightIndex === -1) return -1;
|
||||
return leftIndex - rightIndex;
|
||||
}
|
||||
|
||||
const leftProviderIndex = PROVIDER_SORT_ORDER.indexOf(left.provider);
|
||||
const rightProviderIndex = PROVIDER_SORT_ORDER.indexOf(right.provider);
|
||||
if (leftProviderIndex !== -1 || rightProviderIndex !== -1) {
|
||||
if (leftProviderIndex === -1) return 1;
|
||||
if (rightProviderIndex === -1) return -1;
|
||||
return leftProviderIndex - rightProviderIndex;
|
||||
}
|
||||
|
||||
return modelSpec(left).localeCompare(modelSpec(right));
|
||||
}
|
||||
|
||||
function sortProviders(left: ProviderStatus, right: ProviderStatus): number {
|
||||
if (left.configured !== right.configured) {
|
||||
return left.configured ? -1 : 1;
|
||||
}
|
||||
if (left.current !== right.current) {
|
||||
return left.current ? -1 : 1;
|
||||
}
|
||||
if (left.recommended !== right.recommended) {
|
||||
return left.recommended ? -1 : 1;
|
||||
}
|
||||
const leftIndex = PROVIDER_SORT_ORDER.indexOf(left.id);
|
||||
const rightIndex = PROVIDER_SORT_ORDER.indexOf(right.id);
|
||||
if (leftIndex !== -1 || rightIndex !== -1) {
|
||||
if (leftIndex === -1) return 1;
|
||||
if (rightIndex === -1) return -1;
|
||||
return leftIndex - rightIndex;
|
||||
}
|
||||
return left.label.localeCompare(right.label);
|
||||
}
|
||||
|
||||
function createModelRegistry(authPath: string): ModelRegistry {
|
||||
return new ModelRegistry(AuthStorage.create(authPath));
|
||||
}
|
||||
|
||||
export function getAvailableModelRecords(authPath: string): ModelRecord[] {
|
||||
return createModelRegistry(authPath)
|
||||
.getAvailable()
|
||||
.map((model) => ({ provider: model.provider, id: model.id, name: model.name }));
|
||||
}
|
||||
|
||||
export function getSupportedModelRecords(authPath: string): ModelRecord[] {
|
||||
return createModelRegistry(authPath)
|
||||
.getAll()
|
||||
.map((model) => ({ provider: model.provider, id: model.id, name: model.name }));
|
||||
}
|
||||
|
||||
export function chooseRecommendedModel(authPath: string): { spec: string; reason: string } | undefined {
|
||||
const available = getAvailableModelRecords(authPath).sort(compareByResearchPreference);
|
||||
if (available.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const matchedPreference = RESEARCH_MODEL_PREFERENCES.find((entry) => entry.spec === modelSpec(available[0]!));
|
||||
if (matchedPreference) {
|
||||
return matchedPreference;
|
||||
}
|
||||
|
||||
return {
|
||||
spec: modelSpec(available[0]!),
|
||||
reason: "best currently authenticated fallback for research work",
|
||||
};
|
||||
}
|
||||
|
||||
export function buildModelStatusSnapshotFromRecords(
|
||||
supported: ModelRecord[],
|
||||
available: ModelRecord[],
|
||||
current: string | undefined,
|
||||
): ModelStatusSnapshot {
|
||||
const availableSpecs = available
|
||||
.slice()
|
||||
.sort(compareByResearchPreference)
|
||||
.map((model) => modelSpec(model));
|
||||
const recommended = available.length > 0
|
||||
? (() => {
|
||||
const preferred = available.slice().sort(compareByResearchPreference)[0]!;
|
||||
const matched = RESEARCH_MODEL_PREFERENCES.find((entry) => entry.spec === modelSpec(preferred));
|
||||
return {
|
||||
spec: modelSpec(preferred),
|
||||
reason: matched?.reason ?? "best currently authenticated fallback for research work",
|
||||
};
|
||||
})()
|
||||
: undefined;
|
||||
|
||||
const currentValid = current ? availableSpecs.includes(current) : false;
|
||||
const providerMap = new Map<string, ProviderStatus>();
|
||||
|
||||
for (const model of supported) {
|
||||
const provider = providerMap.get(model.provider) ?? {
|
||||
id: model.provider,
|
||||
label: formatProviderLabel(model.provider),
|
||||
supportedModels: 0,
|
||||
availableModels: 0,
|
||||
configured: false,
|
||||
current: false,
|
||||
recommended: false,
|
||||
};
|
||||
provider.supportedModels += 1;
|
||||
provider.current ||= current?.startsWith(`${model.provider}/`) ?? false;
|
||||
provider.recommended ||= recommended?.spec.startsWith(`${model.provider}/`) ?? false;
|
||||
providerMap.set(model.provider, provider);
|
||||
}
|
||||
|
||||
for (const model of available) {
|
||||
const provider = providerMap.get(model.provider) ?? {
|
||||
id: model.provider,
|
||||
label: formatProviderLabel(model.provider),
|
||||
supportedModels: 0,
|
||||
availableModels: 0,
|
||||
configured: false,
|
||||
current: false,
|
||||
recommended: false,
|
||||
};
|
||||
provider.availableModels += 1;
|
||||
provider.configured = true;
|
||||
provider.current ||= current?.startsWith(`${model.provider}/`) ?? false;
|
||||
provider.recommended ||= recommended?.spec.startsWith(`${model.provider}/`) ?? false;
|
||||
providerMap.set(model.provider, provider);
|
||||
}
|
||||
|
||||
const guidance: string[] = [];
|
||||
if (available.length === 0) {
|
||||
guidance.push("No authenticated Pi models are available yet.");
|
||||
guidance.push("Run `feynman model login <provider>` or add provider credentials that Pi can see.");
|
||||
guidance.push("After auth is in place, rerun `feynman model list` or `feynman setup model`.");
|
||||
} else if (!current) {
|
||||
guidance.push(`No default research model is set. Recommended: ${recommended?.spec}.`);
|
||||
guidance.push("Run `feynman model set <provider/model>` or `feynman setup model`.");
|
||||
} else if (!currentValid) {
|
||||
guidance.push(`Configured default model is unavailable: ${current}.`);
|
||||
if (recommended) {
|
||||
guidance.push(`Switch to the current research recommendation: ${recommended.spec}.`);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
current,
|
||||
currentValid,
|
||||
recommended: recommended?.spec,
|
||||
recommendationReason: recommended?.reason,
|
||||
availableModels: availableSpecs,
|
||||
providers: Array.from(providerMap.values()).sort(sortProviders),
|
||||
guidance,
|
||||
};
|
||||
}
|
||||
273
src/model/commands.ts
Normal file
273
src/model/commands.ts
Normal file
@@ -0,0 +1,273 @@
|
||||
import { AuthStorage } from "@mariozechner/pi-coding-agent";
|
||||
import { writeFileSync } from "node:fs";
|
||||
|
||||
import { readJson } from "../pi/settings.js";
|
||||
import { promptChoice, promptText } from "../setup/prompts.js";
|
||||
import { printInfo, printSection, printSuccess, printWarning } from "../ui/terminal.js";
|
||||
import {
|
||||
buildModelStatusSnapshotFromRecords,
|
||||
chooseRecommendedModel,
|
||||
getAvailableModelRecords,
|
||||
getSupportedModelRecords,
|
||||
type ModelStatusSnapshot,
|
||||
} from "./catalog.js";
|
||||
|
||||
function formatProviderSummaryLine(status: ModelStatusSnapshot["providers"][number]): string {
|
||||
const state = status.configured ? `${status.availableModels} authenticated` : "not authenticated";
|
||||
const flags = [
|
||||
status.current ? "current" : undefined,
|
||||
status.recommended ? "recommended" : undefined,
|
||||
].filter(Boolean);
|
||||
return `${status.label}: ${state}, ${status.supportedModels} supported${flags.length > 0 ? ` (${flags.join(", ")})` : ""}`;
|
||||
}
|
||||
|
||||
function collectModelStatus(settingsPath: string, authPath: string): ModelStatusSnapshot {
|
||||
return buildModelStatusSnapshotFromRecords(
|
||||
getSupportedModelRecords(authPath),
|
||||
getAvailableModelRecords(authPath),
|
||||
getCurrentModelSpec(settingsPath),
|
||||
);
|
||||
}
|
||||
|
||||
type OAuthProviderInfo = {
|
||||
id: string;
|
||||
name?: string;
|
||||
usesCallbackServer?: boolean;
|
||||
};
|
||||
|
||||
function getOAuthProviders(authPath: string): OAuthProviderInfo[] {
|
||||
return AuthStorage.create(authPath).getOAuthProviders() as OAuthProviderInfo[];
|
||||
}
|
||||
|
||||
function resolveOAuthProvider(authPath: string, input: string): OAuthProviderInfo | undefined {
|
||||
const normalizedInput = input.trim().toLowerCase();
|
||||
if (!normalizedInput) {
|
||||
return undefined;
|
||||
}
|
||||
return getOAuthProviders(authPath).find((provider) => provider.id.toLowerCase() === normalizedInput);
|
||||
}
|
||||
|
||||
async function selectOAuthProvider(authPath: string, action: "login" | "logout"): Promise<OAuthProviderInfo | undefined> {
|
||||
const providers = getOAuthProviders(authPath);
|
||||
if (providers.length === 0) {
|
||||
printWarning("No Pi OAuth model providers are available.");
|
||||
return undefined;
|
||||
}
|
||||
if (providers.length === 1) {
|
||||
return providers[0];
|
||||
}
|
||||
|
||||
const choices = providers.map((provider) => `${provider.id} — ${provider.name ?? provider.id}`);
|
||||
choices.push("Cancel");
|
||||
const selection = await promptChoice(`Choose an OAuth provider to ${action}:`, choices, 0);
|
||||
if (selection >= providers.length) {
|
||||
return undefined;
|
||||
}
|
||||
return providers[selection];
|
||||
}
|
||||
|
||||
function resolveAvailableModelSpec(authPath: string, input: string): string | undefined {
|
||||
const normalizedInput = input.trim().toLowerCase();
|
||||
if (!normalizedInput) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const available = getAvailableModelRecords(authPath);
|
||||
const fullSpecMatch = available.find((model) => `${model.provider}/${model.id}`.toLowerCase() === normalizedInput);
|
||||
if (fullSpecMatch) {
|
||||
return `${fullSpecMatch.provider}/${fullSpecMatch.id}`;
|
||||
}
|
||||
|
||||
const exactIdMatches = available.filter((model) => model.id.toLowerCase() === normalizedInput);
|
||||
if (exactIdMatches.length === 1) {
|
||||
return `${exactIdMatches[0]!.provider}/${exactIdMatches[0]!.id}`;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function getCurrentModelSpec(settingsPath: string): string | undefined {
|
||||
const settings = readJson(settingsPath);
|
||||
if (typeof settings.defaultProvider === "string" && typeof settings.defaultModel === "string") {
|
||||
return `${settings.defaultProvider}/${settings.defaultModel}`;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function printModelStatus(settingsPath: string, authPath: string): void {
|
||||
const status = collectModelStatus(settingsPath, authPath);
|
||||
|
||||
printInfo(`Current default model: ${status.current ?? "not set"}`);
|
||||
printInfo(`Current default valid: ${status.currentValid ? "yes" : "no"}`);
|
||||
printInfo(`Authenticated models: ${status.availableModels.length}`);
|
||||
printInfo(`Providers with auth: ${status.providers.filter((provider) => provider.configured).length}`);
|
||||
printInfo(`Research recommendation: ${status.recommended ?? "none available"}`);
|
||||
if (status.recommendationReason) {
|
||||
printInfo(`Recommendation reason: ${status.recommendationReason}`);
|
||||
}
|
||||
|
||||
if (status.providers.length > 0) {
|
||||
printSection("Providers");
|
||||
for (const provider of status.providers) {
|
||||
printInfo(formatProviderSummaryLine(provider));
|
||||
}
|
||||
}
|
||||
|
||||
if (status.guidance.length > 0) {
|
||||
printSection("Next Steps");
|
||||
for (const line of status.guidance) {
|
||||
printWarning(line);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function printModelProviders(settingsPath: string, authPath: string): void {
|
||||
const status = collectModelStatus(settingsPath, authPath);
|
||||
for (const provider of status.providers) {
|
||||
printInfo(formatProviderSummaryLine(provider));
|
||||
}
|
||||
const oauthProviders = getOAuthProviders(authPath);
|
||||
if (oauthProviders.length > 0) {
|
||||
printSection("OAuth Login");
|
||||
for (const provider of oauthProviders) {
|
||||
printInfo(`${provider.id} — ${provider.name ?? provider.id}`);
|
||||
}
|
||||
}
|
||||
if (status.providers.length === 0) {
|
||||
printWarning("No Pi model providers are visible in the current runtime.");
|
||||
}
|
||||
}
|
||||
|
||||
export function printModelList(settingsPath: string, authPath: string): void {
|
||||
const status = collectModelStatus(settingsPath, authPath);
|
||||
if (status.availableModels.length === 0) {
|
||||
printWarning("No authenticated Pi models are currently available.");
|
||||
for (const line of status.guidance) {
|
||||
printInfo(line);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
let lastProvider: string | undefined;
|
||||
for (const spec of status.availableModels) {
|
||||
const [provider] = spec.split("/", 1);
|
||||
if (provider !== lastProvider) {
|
||||
lastProvider = provider;
|
||||
printSection(provider);
|
||||
}
|
||||
const markers = [
|
||||
spec === status.current ? "current" : undefined,
|
||||
spec === status.recommended ? "recommended" : undefined,
|
||||
].filter(Boolean);
|
||||
printInfo(`${spec}${markers.length > 0 ? ` (${markers.join(", ")})` : ""}`);
|
||||
}
|
||||
}
|
||||
|
||||
export function printModelRecommendation(authPath: string): void {
|
||||
const recommendation = chooseRecommendedModel(authPath);
|
||||
if (!recommendation) {
|
||||
printWarning("No authenticated Pi models are available to recommend.");
|
||||
printInfo("Run `feynman model login <provider>` or add provider credentials that Pi can see, then rerun this command.");
|
||||
return;
|
||||
}
|
||||
|
||||
printSuccess(`Recommended model: ${recommendation.spec}`);
|
||||
printInfo(recommendation.reason);
|
||||
}
|
||||
|
||||
export async function loginModelProvider(authPath: string, providerId?: string): Promise<void> {
|
||||
const provider = providerId ? resolveOAuthProvider(authPath, providerId) : await selectOAuthProvider(authPath, "login");
|
||||
if (!provider) {
|
||||
if (providerId) {
|
||||
throw new Error(`Unknown OAuth model provider: ${providerId}`);
|
||||
}
|
||||
printInfo("Login cancelled.");
|
||||
return;
|
||||
}
|
||||
|
||||
const authStorage = AuthStorage.create(authPath);
|
||||
const abortController = new AbortController();
|
||||
|
||||
await authStorage.login(provider.id, {
|
||||
onAuth: (info: { url: string; instructions?: string }) => {
|
||||
printSection(`Login: ${provider.name ?? provider.id}`);
|
||||
printInfo(`Open this URL: ${info.url}`);
|
||||
if (info.instructions) {
|
||||
printInfo(info.instructions);
|
||||
}
|
||||
},
|
||||
onPrompt: async (prompt: { message: string; placeholder?: string }) => {
|
||||
return promptText(prompt.message, prompt.placeholder ?? "");
|
||||
},
|
||||
onProgress: (message: string) => {
|
||||
printInfo(message);
|
||||
},
|
||||
onManualCodeInput: async () => {
|
||||
return promptText("Paste redirect URL or auth code");
|
||||
},
|
||||
signal: abortController.signal,
|
||||
});
|
||||
|
||||
printSuccess(`Model provider login complete: ${provider.id}`);
|
||||
}
|
||||
|
||||
export async function logoutModelProvider(authPath: string, providerId?: string): Promise<void> {
|
||||
const provider = providerId ? resolveOAuthProvider(authPath, providerId) : await selectOAuthProvider(authPath, "logout");
|
||||
if (!provider) {
|
||||
if (providerId) {
|
||||
throw new Error(`Unknown OAuth model provider: ${providerId}`);
|
||||
}
|
||||
printInfo("Logout cancelled.");
|
||||
return;
|
||||
}
|
||||
|
||||
AuthStorage.create(authPath).logout(provider.id);
|
||||
printSuccess(`Model provider logout complete: ${provider.id}`);
|
||||
}
|
||||
|
||||
export function setDefaultModelSpec(settingsPath: string, authPath: string, spec: string): void {
|
||||
const resolvedSpec = resolveAvailableModelSpec(authPath, spec);
|
||||
if (!resolvedSpec) {
|
||||
throw new Error(`Model not available in Pi auth storage: ${spec}. Run \`feynman model list\` first.`);
|
||||
}
|
||||
|
||||
const [provider, ...rest] = resolvedSpec.split("/");
|
||||
const modelId = rest.join("/");
|
||||
const settings = readJson(settingsPath);
|
||||
settings.defaultProvider = provider;
|
||||
settings.defaultModel = modelId;
|
||||
writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + "\n", "utf8");
|
||||
printSuccess(`Default model set to ${resolvedSpec}`);
|
||||
}
|
||||
|
||||
export async function runModelSetup(settingsPath: string, authPath: string): Promise<void> {
|
||||
const status = collectModelStatus(settingsPath, authPath);
|
||||
|
||||
if (status.availableModels.length === 0) {
|
||||
printWarning("No Pi models are currently authenticated for Feynman.");
|
||||
for (const line of status.guidance) {
|
||||
printInfo(line);
|
||||
}
|
||||
printInfo("Tip: run `feynman model login <provider>` if your provider supports Pi OAuth login.");
|
||||
return;
|
||||
}
|
||||
|
||||
const choices = status.availableModels.map((spec) => {
|
||||
const markers = [
|
||||
spec === status.recommended ? "recommended" : undefined,
|
||||
spec === status.current ? "current" : undefined,
|
||||
].filter(Boolean);
|
||||
return `${spec}${markers.length > 0 ? ` (${markers.join(", ")})` : ""}`;
|
||||
});
|
||||
choices.push(`Keep current (${status.current ?? "unset"})`);
|
||||
|
||||
const defaultIndex = status.current ? Math.max(0, status.availableModels.indexOf(status.current)) : 0;
|
||||
const selection = await promptChoice("Select your default research model:", choices, defaultIndex >= 0 ? defaultIndex : 0);
|
||||
|
||||
if (selection >= status.availableModels.length) {
|
||||
printInfo("Skipped (keeping current model)");
|
||||
return;
|
||||
}
|
||||
|
||||
setDefaultModelSpec(settingsPath, authPath, status.availableModels[selection]!);
|
||||
}
|
||||
Reference in New Issue
Block a user