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:
commit
f82a0655f3
5 changed files with 41 additions and 127 deletions
|
|
@ -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 };
|
||||
}
|
||||
|
||||
|
|
|
|||
47
src/cli.ts
47
src/cli.ts
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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", () => {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue