Merge pull request #4124 from jeremymcs/claude/model-agnostic-selection-rmDX3

Remove hard-coded model defaults; use provider stickiness instead
This commit is contained in:
Jeremy McSpadden 2026-04-13 10:36:22 -05:00 committed by GitHub
commit f82a0655f3
5 changed files with 41 additions and 127 deletions

View file

@ -3,45 +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",
"alibaba-dashscope": "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 */
@ -123,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,
@ -503,33 +472,19 @@ export async function findInitialModel(options: {
};
}
// 3. Try saved default from settings
if (defaultProvider && defaultModelId) {
// Guard against stale settings defaults: only use the saved provider/model
// if the provider is actually request-ready (auth/OAuth/CLI ready).
if (modelRegistry.isProviderRequestReady(defaultProvider)) {
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;
}
return { model, thinkingLevel, fallbackMessage: undefined };
// 3. Try saved default from settings — use it exactly as configured.
// Whatever the user chose is what gets used; no silent substitution.
// Skip the saved default if its provider is not request-ready (no auth
// available) so we fall through to an actually-usable model instead of
// returning a stale selection every selector surface would display.
if (defaultProvider && defaultModelId && modelRegistry.isProviderRequestReady(defaultProvider)) {
const found = modelRegistry.find(defaultProvider, defaultModelId);
if (found) {
model = found;
if (defaultThinkingLevel) {
thinkingLevel = defaultThinkingLevel;
}
return { model, thinkingLevel, fallbackMessage: undefined };
}
}
@ -537,16 +492,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

@ -17,7 +17,6 @@ import { initResources, buildResourceLoader, getNewerManagedResourceVersion } fr
import { ensureManagedTools } from './tool-bootstrap.js'
import { loadStoredEnvKeys } from './wizard.js'
import { migratePiCredentials, getPiDefaultModelAndProvider } from './pi-migration.js'
import { shouldMigrateAnthropicToClaudeCode } from './provider-migrations.js'
import { shouldRunOnboarding, runOnboarding } from './onboarding.js'
import chalk from 'chalk'
import { checkForUpdates } from './update-check.js'
@ -532,29 +531,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 (shouldMigrateAnthropicToClaudeCode({
authStorage,
isClaudeCodeReady: modelRegistry.isProviderRequestReady('claude-code'),
defaultProvider: settingsManager.getDefaultProvider(),
})) {
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.
@ -744,29 +720,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 (shouldMigrateAnthropicToClaudeCode({
authStorage,
isClaudeCodeReady: modelRegistry.isProviderRequestReady('claude-code'),
defaultProvider: settingsManager.getDefaultProvider(),
})) {
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

@ -59,16 +59,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", () => {