diff --git a/packages/pi-coding-agent/src/core/model-resolver.ts b/packages/pi-coding-agent/src/core/model-resolver.ts index 3e3b266f7..721140600 100644 --- a/packages/pi-coding-agent/src/core/model-resolver.ts +++ b/packages/pi-coding-agent/src/core/model-resolver.ts @@ -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 = { - "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; /** 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 }; } diff --git a/src/cli.ts b/src/cli.ts index 5009f23b7..2a73e5b2f 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -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. diff --git a/src/help-text.ts b/src/help-text.ts index ab534ae62..59e72389b 100644 --- a/src/help-text.ts +++ b/src/help-text.ts @@ -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 Override model (e.g. claude-opus-4-6)\n') + process.stdout.write(' --model Override model (e.g. provider/model-id)\n') process.stdout.write(' --no-session Disable session persistence\n') process.stdout.write(' --extension Load additional extension\n') process.stdout.write(' --tools Restrict available tools\n') diff --git a/src/startup-model-validation.ts b/src/startup-model-validation.ts index 1a4141f00..e7a00d849 100644 --- a/src/startup-model-validation.ts +++ b/src/startup-model-validation.ts @@ -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) diff --git a/src/tests/startup-model-validation.test.ts b/src/tests/startup-model-validation.test.ts index fc124a132..01b43c98c 100644 --- a/src/tests/startup-model-validation.test.ts +++ b/src/tests/startup-model-validation.test.ts @@ -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", () => {