Make model selection model-agnostic

Remove hard-coded Anthropic/Claude defaults and silent provider swaps so
the app honors whatever model/provider the user has configured.

- src/cli.ts: drop the anthropic->claude-code auto-migration blocks that
  were rewriting the user's saved defaultProvider on every startup.
- packages/pi-coding-agent/src/core/model-resolver.ts: delete the
  defaultModelPerProvider table, drop the "recommended variant" swap
  that silently upgraded e.g. claude-opus-4-6 to -extended, and replace
  the provider-iteration first-available fallback with provider-sticky
  (user's saved provider first, then first registry entry).
- src/startup-model-validation.ts: replace the openai/anthropic-first
  fallback chain with Pi-default -> same-provider -> first-available.
- src/help-text.ts: use a generic provider/model-id example for --model
  instead of claude-opus-4-6.
- src/tests/startup-model-validation.test.ts: update the fallback test
  to assert provider stickiness rather than a specific Claude model id.

https://claude.ai/code/session_01CvuUuzuVjRcQN25263nG6V
This commit is contained in:
Claude 2026-04-13 14:03:35 +00:00
parent 804f1d4b94
commit 0ed576ac00
5 changed files with 31 additions and 106 deletions

View file

@ -3,44 +3,13 @@
*/
import type { ThinkingLevel } from "@gsd/pi-agent-core";
import { type Api, type KnownProvider, type Model, modelsAreEqual } from "@gsd/pi-ai";
import { type Api, type Model, modelsAreEqual } from "@gsd/pi-ai";
import chalk from "chalk";
import { minimatch } from "minimatch";
import { isValidThinkingLevel } from "../cli/args.js";
import { DEFAULT_THINKING_LEVEL } from "./defaults.js";
import type { ModelRegistry } from "./model-registry.js";
/** Default model IDs for each known provider */
const defaultModelPerProvider: Record<KnownProvider, string> = {
"amazon-bedrock": "us.anthropic.claude-opus-4-6-v1",
anthropic: "claude-opus-4-6",
"anthropic-vertex": "claude-sonnet-4-6",
openai: "gpt-5.4",
"azure-openai-responses": "gpt-5.2",
"openai-codex": "gpt-5.4",
google: "gemini-2.5-pro",
"google-gemini-cli": "gemini-2.5-pro",
"google-antigravity": "gemini-3.1-pro-high",
"google-vertex": "gemini-3-pro-preview",
"github-copilot": "gpt-4o",
openrouter: "openai/gpt-5.1-codex",
"vercel-ai-gateway": "anthropic/claude-opus-4-6",
xai: "grok-4-fast-non-reasoning",
groq: "openai/gpt-oss-120b",
cerebras: "zai-glm-4.6",
zai: "glm-4.6",
mistral: "devstral-medium-latest",
minimax: "MiniMax-M2.1",
"minimax-cn": "MiniMax-M2.1",
huggingface: "moonshotai/Kimi-K2.5",
opencode: "claude-opus-4-6",
"opencode-go": "kimi-k2.5",
"kimi-coding": "kimi-k2-thinking",
"alibaba-coding-plan": "qwen3.5-plus",
ollama: "llama3.1:8b",
"ollama-cloud": "qwen3:32b",
};
export interface ScopedModel {
model: Model<Api>;
/** Thinking level if explicitly specified in pattern (e.g., "model:high"), undefined otherwise */
@ -122,10 +91,11 @@ function buildFallbackModel(provider: string, modelId: string, availableModels:
const providerModels = availableModels.filter((m) => m.provider === provider);
if (providerModels.length === 0) return undefined;
const defaultId = defaultModelPerProvider[provider as KnownProvider];
const baseModel = defaultId
? (providerModels.find((m) => m.id === defaultId) ?? providerModels[0])
: providerModels[0];
// Use the first available model from this provider as a template for
// capabilities (context window, reasoning support, etc.). The user is
// explicitly providing a custom model id, so we just need any shape of
// model from the same provider to inherit from.
const baseModel = providerModels[0];
return {
...baseModel,
@ -502,24 +472,11 @@ export async function findInitialModel(options: {
};
}
// 3. Try saved default from settings
// 3. Try saved default from settings — use it exactly as configured.
// Whatever the user chose is what gets used; no silent substitution.
if (defaultProvider && defaultModelId) {
const found = modelRegistry.find(defaultProvider, defaultModelId);
if (found) {
// Check if the provider's recommended default is a higher-capability variant
// of the saved model (e.g. saved "claude-opus-4-6" vs recommended "claude-opus-4-6-extended").
// If so, prefer the recommended variant to avoid using a smaller context window (#1125).
const recommendedId = defaultModelPerProvider[defaultProvider as KnownProvider];
if (recommendedId && recommendedId !== defaultModelId && recommendedId.startsWith(defaultModelId)) {
const recommended = modelRegistry.find(defaultProvider, recommendedId);
if (recommended) {
model = recommended;
if (defaultThinkingLevel) {
thinkingLevel = defaultThinkingLevel;
}
return { model, thinkingLevel, fallbackMessage: undefined };
}
}
model = found;
if (defaultThinkingLevel) {
thinkingLevel = defaultThinkingLevel;
@ -532,16 +489,17 @@ export async function findInitialModel(options: {
const availableModels = await modelRegistry.getAvailable();
if (availableModels.length > 0) {
// Try to find a default model from known providers
for (const provider of Object.keys(defaultModelPerProvider) as KnownProvider[]) {
const defaultId = defaultModelPerProvider[provider];
const match = availableModels.find((m) => m.provider === provider && m.id === defaultId);
if (match) {
return { model: match, thinkingLevel: DEFAULT_THINKING_LEVEL, fallbackMessage: undefined };
// Prefer a model from the user's saved provider if any is still available —
// provider stickiness, not a hard-coded Anthropic/OpenAI preference.
if (defaultProvider) {
const sameProvider = availableModels.find((m) => m.provider === defaultProvider);
if (sameProvider) {
return { model: sameProvider, thinkingLevel: DEFAULT_THINKING_LEVEL, fallbackMessage: undefined };
}
}
// If no default found, use first available
// Otherwise use the first available — registry order reflects models.json
// order, which the user controls.
return { model: availableModels[0], thinkingLevel: DEFAULT_THINKING_LEVEL, fallbackMessage: undefined };
}

View file

@ -467,25 +467,6 @@ if (isPrintMode) {
})
markStartup('createAgentSession')
// Migrate anthropic OAuth users to claude-code provider when CLI is available (#3772).
// Anthropic blocks third-party apps from using subscription quotas — routing through
// the local claude CLI binary is TOS-compliant.
if (modelRegistry.isProviderRequestReady('claude-code') && settingsManager.getDefaultProvider() === 'anthropic') {
const currentModelId = settingsManager.getDefaultModel()
if (currentModelId) {
const ccModel = modelRegistry.find('claude-code', currentModelId)
if (ccModel) {
try {
await session.setModel(ccModel)
// Only persist after successful session switch to avoid desync
settingsManager.setDefaultModelAndProvider('claude-code', currentModelId)
} catch {
// claude-code provider not ready — leave both session and settings unchanged
}
}
}
}
// Validate configured model AFTER extensions have registered their models (#2626).
// Before this, extension-provided models (e.g. claude-code/*) were not yet in the
// registry, causing the user's valid choice to be silently overwritten.
@ -659,25 +640,6 @@ const { session, extensionsResult, modelFallbackMessage: interactiveFallbackMsg
})
markStartup('createAgentSession')
// Migrate anthropic OAuth users to claude-code provider when CLI is available (#3772).
// Anthropic blocks third-party apps from using subscription quotas — routing through
// the local claude CLI binary is TOS-compliant.
if (modelRegistry.isProviderRequestReady('claude-code') && settingsManager.getDefaultProvider() === 'anthropic') {
const currentModelId = settingsManager.getDefaultModel()
if (currentModelId) {
const ccModel = modelRegistry.find('claude-code', currentModelId)
if (ccModel) {
try {
await session.setModel(ccModel)
// Only persist after successful session switch to avoid desync
settingsManager.setDefaultModelAndProvider('claude-code', currentModelId)
} catch {
// claude-code provider not ready — leave both session and settings unchanged
}
}
}
}
// Validate configured model AFTER extensions have registered their models (#2626).
// Before this, extension-provided models (e.g. claude-code/*) were not yet in the
// registry, causing the user's valid choice to be silently overwritten.

View file

@ -157,7 +157,7 @@ export function printHelp(version: string): void {
process.stdout.write(' --print, -p Single-shot print mode\n')
process.stdout.write(' --continue, -c Resume the most recent session\n')
process.stdout.write(' --worktree, -w [name] Start in an isolated worktree (auto-named if omitted)\n')
process.stdout.write(' --model <id> Override model (e.g. claude-opus-4-6)\n')
process.stdout.write(' --model <id> Override model (e.g. provider/model-id)\n')
process.stdout.write(' --no-session Disable session persistence\n')
process.stdout.write(' --extension <path> Load additional extension\n')
process.stdout.write(' --tools <a,b,c> Restrict available tools\n')

View file

@ -56,16 +56,19 @@ export function validateConfiguredModel(
if (!configuredModel || !configuredExists) {
// Model not configured at all, or removed from registry — pick a fallback.
// Only fires when the model is genuinely unknown (not just temporarily unavailable).
//
// Model-agnostic selection order:
// 1. Pi migration default (preserves migration from ~/.pi install)
// 2. Any model from the user's previously-chosen provider (provider stickiness)
// 3. First available model in registry order (user-controlled via models.json)
const piDefault = getPiDefaultModelAndProvider()
const preferred =
(piDefault
? availableModels.find((m) => m.provider === piDefault.provider && m.id === piDefault.model)
: undefined) ||
availableModels.find((m) => m.provider === 'openai' && m.id === 'gpt-5.4') ||
availableModels.find((m) => m.provider === 'openai') ||
availableModels.find((m) => m.provider === 'anthropic' && m.id === 'claude-opus-4-6') ||
availableModels.find((m) => m.provider === 'anthropic' && m.id.includes('opus')) ||
availableModels.find((m) => m.provider === 'anthropic') ||
(configuredProvider
? availableModels.find((m) => m.provider === configuredProvider)
: undefined) ||
availableModels[0]
if (preferred) {
settingsManager.setDefaultModelAndProvider(preferred.provider, preferred.id)

View file

@ -78,8 +78,10 @@ describe("validateConfiguredModel — regression #3534", () => {
assert.notEqual(settings._model, "claude-opus-4-6[1m]");
});
it("does not fall back to google when anthropic models are available", () => {
// Simulate: stale setting triggers fallback, anthropic should be preferred over google
it("prefers the user's saved provider when falling back", () => {
// Simulate: stale model triggers fallback. The fallback should stay on
// the user's chosen provider rather than silently jumping to a different
// one — model-agnostic provider stickiness, not a hard-coded preference.
const registry = createMockRegistry([
{ provider: "anthropic", id: "claude-opus-4-6" },
{ provider: "google", id: "gemini-2.5-pro" },
@ -88,9 +90,9 @@ describe("validateConfiguredModel — regression #3534", () => {
validateConfiguredModel(registry, settings);
// Should pick anthropic fallback, not google
// Provider stickiness: should stay on anthropic, since a model from
// that provider is still available.
assert.equal(settings._provider, "anthropic");
assert.equal(settings._model, "claude-opus-4-6");
});
it("resets thinking level when model is replaced", () => {