diff --git a/packages/ai/src/provider-models/descriptors.ts b/packages/ai/src/provider-models/descriptors.ts index 4f7c44c19..e78168c99 100644 --- a/packages/ai/src/provider-models/descriptors.ts +++ b/packages/ai/src/provider-models/descriptors.ts @@ -132,7 +132,7 @@ function catalogDescriptor( * openai-codex) are handled separately because they require different config shapes. */ export const PROVIDER_DESCRIPTORS: readonly ProviderDescriptor[] = [ - descriptor("anthropic", "claude-sonnet-4-6", config => anthropicModelManagerOptions(config)), + descriptor("anthropic", "claude-opus-4-6", config => anthropicModelManagerOptions(config)), catalogDescriptor( "alibaba-coding-plan", "qwen3.5-plus", diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 61e0321f0..4e8aba2ff 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -1,15 +1,20 @@ # Changelog ## [Unreleased] - ### Added - Added `/model` visibility for auto-selected role defaults: inferred `pi/smol`/`pi/slow`/designer choices now show as compact `[ROLE auto]` badges, while explicitly configured roles keep the existing solid badges and thinking labels. - - Added credential provenance to the `/login` and `/logout` provider picker: each authenticated provider now shows where its credential comes from — `(login)`, `(api key)`, `(env: VAR_NAME)`, `(config)`, `(--api-key)`, or `(custom provider)` — so a real OAuth login is distinguishable from an env var that merely aliases the provider (e.g. `COPILOT_GITHUB_TOKEN`). The origin is also matched by the picker's type-to-search filter. +### Changed + +- Changed model resolution to apply provider-priority ordering when selecting models for roles and ambiguous patterns, using `modelProviderOrder` settings and built-in provider priority so first-party providers are preferred over relays in tie cases +- Changed model canonical variant selection to use the same provider-priority ordering instead of candidate order when deduplicating equivalent upstream models + ### Fixed +- Fixed startup model fallback selection so sessions now prefer each provider’s configured default model before choosing the first available authenticated model +- Fixed implicit model selection path for tools and sessions by honoring persisted model-provider order when no explicit pattern is provided - Fixed the working spinner appearing to ignore Esc for 2-3 seconds when an interrupt lands mid-tool. Esc fires the abort synchronously, but the agent loop only stops the loader at `agent_end`, which it cannot reach until every in-flight tool settles in `executeToolCalls`' `await Promise.allSettled(...)` — and process/subagent/kernel-owning tools tear down gracefully (SIGTERM, 2-3s grace, SIGKILL), so the loader kept showing the unchanged "Working…/" line and read as a dropped keypress. The loader now switches to "Interrupting…" the instant Esc requests the abort and freezes intent-driven label updates until the turn unwinds (`EventController.notifyInterrupting`), so the interrupt is acknowledged immediately even while teardown completes. - Fixed a flaky JS eval worker startup that intermittently failed unrelated CI runs. The worker-ready wait reused Bun's 5s default per-test timeout as its floor, so a slow cold-start under `--isolate` + high concurrency was aborted mid-init; terminating a still-initializing Bun worker is the documented SIGILL/SIGTRAP crash trigger, which took down the whole test file. Worker init now floors at a fixed 15s infrastructure budget (independent of, and still dominated by, a larger per-cell `timeout`), and the JS eval test suites set a 20s file-local timeout so cold starts complete instead of being torn down. - Fixed reviewer-style subagent yields crashing the calling eval cell when a caller-supplied output schema declares `additionalProperties: false` without a `findings` property. `normalizeCompleteData` now consults the active validator before splicing collected `report_finding` entries onto the yielded payload, so injection is suppressed when the schema would reject it — keeping the executor's post-mortem validation in lockstep with the in-tool `yield` validation that already accepted the same raw payload ([#2070](https://github.com/can1357/oh-my-pi/issues/2070)) @@ -9592,4 +9597,4 @@ Initial public release. - Git branch display in footer - Message queueing during streaming responses - OAuth integration for Gmail and Google Calendar access -- HTML export with syntax highlighting and collapsible sections +- HTML export with syntax highlighting and collapsible sections \ No newline at end of file diff --git a/packages/coding-agent/src/cli/dry-balance-cli.ts b/packages/coding-agent/src/cli/dry-balance-cli.ts index 85e9388c5..72cd26868 100644 --- a/packages/coding-agent/src/cli/dry-balance-cli.ts +++ b/packages/coding-agent/src/cli/dry-balance-cli.ts @@ -19,7 +19,7 @@ import type { CanonicalModelVariant } from "../config/model-equivalence"; import { type CanonicalModelQueryOptions, ModelRegistry } from "../config/model-registry"; import { formatModelString, - type ModelMatchPreferences, + getModelMatchPreferences, resolveAllowedModels, resolveCliModel, resolveModelRoleValue, @@ -542,9 +542,7 @@ async function resolveDryBalanceModel( settings: Settings | undefined, randomSessionId: () => string, ): Promise<{ model: Model; warning?: string }> { - const preferences: ModelMatchPreferences = { - usageOrder: settings?.getStorage()?.getModelUsageOrder(), - }; + const preferences = getModelMatchPreferences(settings); if (modelSelector) { const resolved = resolveCliModel({ cliModel: modelSelector, diff --git a/packages/coding-agent/src/commit/model-selection.ts b/packages/coding-agent/src/commit/model-selection.ts index 135bb6be7..d5ffa73c3 100644 --- a/packages/coding-agent/src/commit/model-selection.ts +++ b/packages/coding-agent/src/commit/model-selection.ts @@ -3,6 +3,7 @@ import type { Api, ApiKey, Model } from "@oh-my-pi/pi-ai"; import type { ApiKeyResolverRegistry } from "../config/api-key-resolver"; import { MODEL_ROLE_IDS } from "../config/model-registry"; import { + getModelMatchPreferences, type ModelLookupRegistry, parseModelPattern, resolveModelRoleValue, @@ -33,7 +34,7 @@ export async function resolvePrimaryModel( modelRegistry: CommitModelRegistry, ): Promise { const available = modelRegistry.getAvailable(); - const matchPreferences = { usageOrder: settings.getStorage()?.getModelUsageOrder() }; + const matchPreferences = getModelMatchPreferences(settings); const resolved = override ? resolveModelRoleValue(override, available, { settings, matchPreferences, modelRegistry }) : resolveRoleSelection(["commit", "smol", ...MODEL_ROLE_IDS], settings, available, modelRegistry); @@ -73,7 +74,7 @@ export async function resolveSmolModel( } } - const matchPreferences = { usageOrder: settings.getStorage()?.getModelUsageOrder() }; + const matchPreferences = getModelMatchPreferences(settings); for (const pattern of MODEL_PRIO.smol) { const candidate = parseModelPattern(pattern, available, matchPreferences, { modelRegistry }).model; if (!candidate) continue; diff --git a/packages/coding-agent/src/config/model-provider-priority.ts b/packages/coding-agent/src/config/model-provider-priority.ts new file mode 100644 index 000000000..0fc35e6f3 --- /dev/null +++ b/packages/coding-agent/src/config/model-provider-priority.ts @@ -0,0 +1,55 @@ +const DEFAULT_MODEL_PROVIDER_ORDER = [ + // First-party / native account providers. Prefer these over relays when the + // same upstream model is available in more than one place. + "openai-codex", + "anthropic", + "openai", + "google-gemini-cli", + "google", + "google-vertex", + "kimi-code", + "moonshot", + "qwen-portal", + "zai", + "xai-oauth", + "xai", + "mistral", + "deepseek", + "groq", + + // High-quality aggregators / hosted inference providers. + "fireworks", + "cerebras", + "openrouter", + "together", + + // Generic gateways and editor/proxy providers. These are useful when picked + // explicitly, but should not win ambiguous automatic role selection. + "alibaba-coding-plan", + "google-antigravity", + "opencode-zen", + "gitlab-duo", + "opencode-go", + "kilo", + "vercel-ai-gateway", + "cloudflare-ai-gateway", + "nanogpt", + "github-copilot", +] as const; + +function addProviderRank(rank: Map, provider: string): void { + const normalized = provider.trim().toLowerCase(); + if (!normalized || rank.has(normalized)) return; + rank.set(normalized, rank.size); +} + +export function buildModelProviderPriorityRank(configuredProviderOrder?: readonly string[]): Map { + const rank = new Map(); + for (const provider of configuredProviderOrder ?? []) { + addProviderRank(rank, provider); + } + for (const provider of DEFAULT_MODEL_PROVIDER_ORDER) { + addProviderRank(rank, provider); + } + return rank; +} diff --git a/packages/coding-agent/src/config/model-registry.ts b/packages/coding-agent/src/config/model-registry.ts index 6b0a86a23..7006b17d7 100644 --- a/packages/coding-agent/src/config/model-registry.ts +++ b/packages/coding-agent/src/config/model-registry.ts @@ -118,6 +118,7 @@ import { getModelLikeIdSegments, stripBracketedModelIdAffixes, } from "./model-id-affixes"; +import { buildModelProviderPriorityRank } from "./model-provider-priority"; import { type ModelOverride, type ModelsConfig, @@ -2208,27 +2209,8 @@ export class ModelRegistry { }); } - #providerRank(models: readonly Model[]): Map { - const configuredProviders = getConfiguredProviderOrderFromSettings(); - const result = new Map(); - let nextRank = 0; - for (const provider of configuredProviders) { - const normalized = provider.trim().toLowerCase(); - if (!normalized || result.has(normalized)) { - continue; - } - result.set(normalized, nextRank); - nextRank += 1; - } - for (const model of models) { - const normalized = model.provider.toLowerCase(); - if (result.has(normalized)) { - continue; - } - result.set(normalized, nextRank); - nextRank += 1; - } - return result; + #providerRank(): Map { + return buildModelProviderPriorityRank(getConfiguredProviderOrderFromSettings()); } #resolveCanonicalVariant( @@ -2238,7 +2220,7 @@ export class ModelRegistry { if (variants.length === 0) { return undefined; } - const providerRank = this.#providerRank(allCandidates); + const providerRank = this.#providerRank(); const modelOrder = new Map(); for (let index = 0; index < allCandidates.length; index += 1) { modelOrder.set(formatCanonicalVariantSelector(allCandidates[index]!), index); diff --git a/packages/coding-agent/src/config/model-resolver.ts b/packages/coding-agent/src/config/model-resolver.ts index 378a325a7..a01fa4f3f 100644 --- a/packages/coding-agent/src/config/model-resolver.ts +++ b/packages/coding-agent/src/config/model-resolver.ts @@ -17,6 +17,7 @@ import { logger } from "@oh-my-pi/pi-utils"; import chalk from "chalk"; import MODEL_PRIO from "../priority.json" with { type: "json" }; import { parseThinkingLevel, resolveThinkingLevelForModel } from "../thinking"; +import { buildModelProviderPriorityRank } from "./model-provider-priority"; import { isAuthenticated, kNoAuth, MODEL_ROLE_IDS, type ModelRegistry, type ModelRole } from "./model-registry"; import type { Settings } from "./settings"; @@ -179,7 +180,9 @@ export function resolveProviderModelReference( export interface ModelMatchPreferences { /** Most-recently-used model keys (provider/modelId) to prefer when ambiguous. */ usageOrder?: string[]; - /** Providers to deprioritize when no recent usage is available. */ + /** Provider precedence used for ambiguous unqualified model patterns. */ + providerOrder?: readonly string[]; + /** Providers to deprioritize when no recent usage or provider priority is available. */ deprioritizeProviders?: string[]; } @@ -194,6 +197,7 @@ type RestorableModelRegistry = Pick; providerUsageRank: Map; + providerPriorityRank: Map; deprioritizedProviders: Set; modelOrder: Map; } @@ -215,14 +219,35 @@ function buildPreferenceContext( providerUsageRank.set(parsed.provider, i); } } - - const deprioritizedProviders = new Set(preferences?.deprioritizeProviders ?? ["openrouter"]); + const providerPriorityRank = buildModelProviderPriorityRank(preferences?.providerOrder); + const deprioritizedProviders = new Set(preferences?.deprioritizeProviders ?? []); const modelOrder = new Map(); for (let i = 0; i < availableModels.length; i += 1) { modelOrder.set(formatModelString(availableModels[i]), i); } - return { modelUsageRank, providerUsageRank, deprioritizedProviders, modelOrder }; + return { modelUsageRank, providerUsageRank, providerPriorityRank, deprioritizedProviders, modelOrder }; +} + +export function getModelMatchPreferences( + settings?: Partial>, +): ModelMatchPreferences { + return { + usageOrder: settings?.getStorage?.()?.getModelUsageOrder(), + providerOrder: settings?.get?.("modelProviderOrder"), + }; +} + +function mergeModelMatchPreferences( + settings: Settings | undefined, + preferences: ModelMatchPreferences | undefined, +): ModelMatchPreferences { + const settingsPreferences = getModelMatchPreferences(settings); + return { + usageOrder: preferences?.usageOrder ?? settingsPreferences.usageOrder, + providerOrder: preferences?.providerOrder ?? settingsPreferences.providerOrder, + deprioritizeProviders: preferences?.deprioritizeProviders, + }; } function pickPreferredModel(candidates: Model[], context: ModelPreferenceContext): Model { @@ -236,6 +261,12 @@ function pickPreferredModel(candidates: Model[], context: ModelPreferenceCo return (aUsage ?? Number.POSITIVE_INFINITY) - (bUsage ?? Number.POSITIVE_INFINITY); } + const aProviderPriority = context.providerPriorityRank.get(a.provider.toLowerCase()); + const bProviderPriority = context.providerPriorityRank.get(b.provider.toLowerCase()); + if (aProviderPriority !== undefined || bProviderPriority !== undefined) { + return (aProviderPriority ?? Number.POSITIVE_INFINITY) - (bProviderPriority ?? Number.POSITIVE_INFINITY); + } + const aProviderUsage = context.providerUsageRank.get(a.provider); const bProviderUsage = context.providerUsageRank.get(b.provider); if (aProviderUsage !== undefined || bProviderUsage !== undefined) { @@ -618,8 +649,9 @@ export function resolveModelRoleValue( } let warning: string | undefined; + const matchPreferences = mergeModelMatchPreferences(options?.settings, options?.matchPreferences); for (const effectivePattern of effectivePatterns) { - const resolved = parseModelPattern(effectivePattern, availableModels, options?.matchPreferences, { + const resolved = parseModelPattern(effectivePattern, availableModels, matchPreferences, { modelRegistry: options?.modelRegistry, }); if (resolved.model) { @@ -720,7 +752,7 @@ export function resolveModelOverride( ): { model?: Model; thinkingLevel?: ThinkingLevel; explicitThinkingLevel: boolean } { if (modelPatterns.length === 0) return { explicitThinkingLevel: false }; const availableModels = modelRegistry.getAvailable(); - const matchPreferences = { usageOrder: settings?.getStorage()?.getModelUsageOrder() }; + const matchPreferences = getModelMatchPreferences(settings); for (const pattern of modelPatterns) { const { model, thinkingLevel, explicitThinkingLevel } = resolveModelRoleValue(pattern, availableModels, { settings, @@ -800,7 +832,7 @@ export function resolveRoleSelection( availableModels: Model[], modelRegistry?: CanonicalModelRegistry, ): { model: Model; thinkingLevel?: ThinkingLevel } | undefined { - const matchPreferences = { usageOrder: settings.getStorage()?.getModelUsageOrder() }; + const matchPreferences = getModelMatchPreferences(settings); for (const role of roles) { const resolved = resolveModelRoleValue(settings.getModelRole(role), availableModels, { settings, diff --git a/packages/coding-agent/src/eval/llm-bridge.ts b/packages/coding-agent/src/eval/llm-bridge.ts index 1f37dc2c9..061e10201 100644 --- a/packages/coding-agent/src/eval/llm-bridge.ts +++ b/packages/coding-agent/src/eval/llm-bridge.ts @@ -16,7 +16,12 @@ import { type Api, Effort, getSupportedEfforts, type Model, type Tool } from "@o import * as z from "zod/v4"; import { extractTextContent, extractToolCall, parseJsonPayload } from "../commit/utils"; -import { expandRoleAlias, formatModelString, resolveModelFromString } from "../config/model-resolver"; +import { + expandRoleAlias, + formatModelString, + getModelMatchPreferences, + resolveModelFromString, +} from "../config/model-resolver"; import type { ToolSession } from "../tools"; import { ToolError } from "../tools/tool-errors"; import { withBridgeTimeoutPause } from "./bridge-timeout"; @@ -65,7 +70,7 @@ function resolveTierModel(tier: LlmTier, session: ToolSession): Model | und const available = modelRegistry.getAvailable(); if (available.length === 0) return undefined; - const matchPreferences = { usageOrder: session.settings.getStorage()?.getModelUsageOrder() }; + const matchPreferences = getModelMatchPreferences(session.settings); const resolve = (pattern: string | undefined): Model | undefined => { if (!pattern) return undefined; const expanded = expandRoleAlias(pattern, session.settings); diff --git a/packages/coding-agent/src/main.ts b/packages/coding-agent/src/main.ts index ae36a43ce..b36a59eba 100644 --- a/packages/coding-agent/src/main.ts +++ b/packages/coding-agent/src/main.ts @@ -29,7 +29,13 @@ import { selectSession } from "./cli/session-picker"; import { applyStartupCwd } from "./cli/startup-cwd"; import { findConfigFile } from "./config"; import { ModelRegistry, ModelsConfigFile } from "./config/model-registry"; -import { resolveCliModel, resolveModelRoleValue, resolveModelScope, type ScopedModel } from "./config/model-resolver"; +import { + getModelMatchPreferences, + resolveCliModel, + resolveModelRoleValue, + resolveModelScope, + type ScopedModel, +} from "./config/model-resolver"; import { getDefault, type SettingPath, Settings, settings } from "./config/settings"; import { initializeWithSettings } from "./discovery"; import { @@ -610,9 +616,7 @@ async function buildSessionOptions( // Model from CLI // - supports --provider --model // - supports --model / - const modelMatchPreferences = { - usageOrder: activeSettings.getStorage()?.getModelUsageOrder(), - }; + const modelMatchPreferences = getModelMatchPreferences(activeSettings); if (parsed.model) { const resolved = resolveCliModel({ cliProvider: parsed.provider, @@ -904,9 +908,7 @@ export async function runRootCommand( let scopedModels: ScopedModel[] = []; const modelPatterns = parsedArgs.models ?? settingsInstance.get("enabledModels"); - const modelMatchPreferences = { - usageOrder: settingsInstance.getStorage()?.getModelUsageOrder(), - }; + const modelMatchPreferences = getModelMatchPreferences(settingsInstance); if (modelPatterns && modelPatterns.length > 0) { scopedModels = await logger.time( "resolveModelScope", diff --git a/packages/coding-agent/src/memories/index.ts b/packages/coding-agent/src/memories/index.ts index 38205c5e2..acd9d8c78 100644 --- a/packages/coding-agent/src/memories/index.ts +++ b/packages/coding-agent/src/memories/index.ts @@ -7,7 +7,7 @@ import { type ApiKey, clampThinkingLevelForModel, completeSimple, Effort, type M import { getAgentDbPath, getMemoriesDir, logger, parseJsonlLenient, prompt } from "@oh-my-pi/pi-utils"; import type { ModelRegistry } from "../config/model-registry"; -import { resolveModelRoleValue } from "../config/model-resolver"; +import { getModelMatchPreferences, resolveModelRoleValue } from "../config/model-resolver"; import type { Settings } from "../config/settings"; import consolidationTemplate from "../prompts/memories/consolidation.md" with { type: "text" }; import readPathTemplate from "../prompts/memories/read-path.md" with { type: "text" }; @@ -1088,7 +1088,7 @@ async function resolveMemoryModel(options: { if (requestedModel) { const resolved = resolveModelRoleValue(requestedModel, modelRegistry.getAll(), { settings: session.settings, - matchPreferences: { usageOrder: session.settings.getStorage()?.getModelUsageOrder() }, + matchPreferences: getModelMatchPreferences(session.settings), modelRegistry, }); if (resolved.model) return resolved.model; diff --git a/packages/coding-agent/src/modes/components/model-selector.ts b/packages/coding-agent/src/modes/components/model-selector.ts index e06f41f3e..f985727a2 100644 --- a/packages/coding-agent/src/modes/components/model-selector.ts +++ b/packages/coding-agent/src/modes/components/model-selector.ts @@ -17,7 +17,7 @@ import { import { formatNumber } from "@oh-my-pi/pi-utils"; import type { ModelRegistry } from "../../config/model-registry"; import { getKnownRoleIds, getRoleInfo, MODEL_ROLE_IDS, MODEL_ROLES } from "../../config/model-registry"; -import { resolveModelRoleValue } from "../../config/model-resolver"; +import { getModelMatchPreferences, resolveModelRoleValue } from "../../config/model-resolver"; import type { Settings } from "../../config/settings"; import { type ThemeColor, theme } from "../../modes/theme/theme"; import { matchesSelectDown, matchesSelectUp } from "../../modes/utils/keybinding-matchers"; @@ -294,7 +294,7 @@ export class ModelSelectorComponent extends Container { #loadRoleModels(autoCandidateModels?: ReadonlyArray): void { const nextRoles = {} as Record; const allModels = this.#modelRegistry.getAll(); - const matchPreferences = { usageOrder: this.#settings.getStorage()?.getModelUsageOrder() }; + const matchPreferences = getModelMatchPreferences(this.#settings); const knownRoles = getKnownRoleIds(this.#settings); const configuredRoles = new Set(); diff --git a/packages/coding-agent/src/sdk.ts b/packages/coding-agent/src/sdk.ts index 992396621..a62d5cc6d 100644 --- a/packages/coding-agent/src/sdk.ts +++ b/packages/coding-agent/src/sdk.ts @@ -41,7 +41,9 @@ import { createApiKeyResolver } from "./config/api-key-resolver"; import { shouldEnableAppendOnlyContext } from "./config/append-only-context-mode"; import { ModelRegistry } from "./config/model-registry"; import { + defaultModelPerProvider, formatModelString, + getModelMatchPreferences, parseModelPattern, parseModelString, resolveAllowedModels, @@ -1031,9 +1033,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {} const hasServiceTierEntry = existingBranch.some(entry => entry.type === "service_tier_change"); const hasExplicitModel = options.model !== undefined || options.modelPattern !== undefined; - const modelMatchPreferences = { - usageOrder: settings.getStorage()?.getModelUsageOrder(), - }; + const modelMatchPreferences = getModelMatchPreferences(settings); const allowedModels = await logger.time("resolveAllowedModels", () => resolveAllowedModels(modelRegistry, settings, modelMatchPreferences), ); @@ -1554,9 +1554,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {} // Resolve deferred --model pattern now that extension models are registered. if (!model && options.modelPattern) { const availableModels = modelRegistry.getAll(); - const matchPreferences = { - usageOrder: settings.getStorage()?.getModelUsageOrder(), - }; + const matchPreferences = getModelMatchPreferences(settings); const { model: resolved } = parseModelPattern(options.modelPattern, availableModels, matchPreferences, { modelRegistry, }); @@ -1575,12 +1573,30 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {} // Re-resolve the allowed set: extension factories above may have // registered providers/models that weren't visible at startup. const fallbackCandidates = await resolveAllowedModels(modelRegistry, settings, modelMatchPreferences); - for (const candidate of fallbackCandidates) { - if (await hasModelApiKey(candidate)) { - model = candidate; + // Prefer each provider's configured default model + // (DEFAULT_MODEL_PER_PROVIDER) over raw catalog order. Without this the + // first-run fallback picks whatever model sorts first in models.json for + // the winning provider (e.g. anthropic's claude-3-5-sonnet-20240620) + // instead of the intended provider default (claude-sonnet-4-6). Mirrors + // findInitialModel's precedence. + for (const [provider, defaultId] of Object.entries(defaultModelPerProvider)) { + const preferred = fallbackCandidates.find( + candidate => candidate.provider === provider && candidate.id === defaultId, + ); + if (preferred && (await hasModelApiKey(preferred))) { + model = preferred; break; } } + // Otherwise, first available model with a valid API key. + if (!model) { + for (const candidate of fallbackCandidates) { + if (await hasModelApiKey(candidate)) { + model = candidate; + break; + } + } + } if (model) { if (modelFallbackMessage) { modelFallbackMessage += `. Using ${model.provider}/${model.id}`; diff --git a/packages/coding-agent/src/session/agent-session.ts b/packages/coding-agent/src/session/agent-session.ts index cffaf46a0..927c13cc6 100644 --- a/packages/coding-agent/src/session/agent-session.ts +++ b/packages/coding-agent/src/session/agent-session.ts @@ -109,6 +109,7 @@ import { extractExplicitThinkingSelector, formatModelSelectorValue, formatModelString, + getModelMatchPreferences, parseModelString, type ResolvedModelRoleValue, resolveModelRoleValue, @@ -5450,7 +5451,7 @@ export class AgentSession { const currentModel = this.model; if (!currentModel) return undefined; - const matchPreferences = { usageOrder: this.settings.getStorage()?.getModelUsageOrder() }; + const matchPreferences = getModelMatchPreferences(this.settings); const models: ResolvedRoleModel[] = []; for (const role of roleOrder) { @@ -7166,7 +7167,7 @@ export class AgentSession { return resolveModelRoleValue(roleModelStr, availableModels, { settings: this.settings, - matchPreferences: { usageOrder: this.settings.getStorage()?.getModelUsageOrder() }, + matchPreferences: getModelMatchPreferences(this.settings), modelRegistry: this.#modelRegistry, }); } diff --git a/packages/coding-agent/src/tools/inspect-image.ts b/packages/coding-agent/src/tools/inspect-image.ts index e7db9fc3c..b7ab63ff6 100644 --- a/packages/coding-agent/src/tools/inspect-image.ts +++ b/packages/coding-agent/src/tools/inspect-image.ts @@ -5,7 +5,7 @@ import { prompt } from "@oh-my-pi/pi-utils"; import * as z from "zod/v4"; import { extractTextContent } from "../commit/utils"; -import { expandRoleAlias, resolveModelFromString } from "../config/model-resolver"; +import { expandRoleAlias, getModelMatchPreferences, resolveModelFromString } from "../config/model-resolver"; import inspectImageDescription from "../prompts/tools/inspect-image.md" with { type: "text" }; import inspectImageSystemPromptTemplate from "../prompts/tools/inspect-image-system.md" with { type: "text" }; import { @@ -72,7 +72,7 @@ export class InspectImageTool implements AgentTool | undefined => { if (!pattern) return undefined; const expanded = expandRoleAlias(pattern, this.session.settings); diff --git a/packages/coding-agent/src/utils/commit-message-generator.ts b/packages/coding-agent/src/utils/commit-message-generator.ts index 7346895c2..48f44bce9 100644 --- a/packages/coding-agent/src/utils/commit-message-generator.ts +++ b/packages/coding-agent/src/utils/commit-message-generator.ts @@ -8,7 +8,7 @@ import { completeSimple } from "@oh-my-pi/pi-ai"; import { logger, prompt } from "@oh-my-pi/pi-utils"; import type { ModelRegistry } from "../config/model-registry"; -import { resolveModelRoleValue } from "../config/model-resolver"; +import { getModelMatchPreferences, resolveModelRoleValue } from "../config/model-resolver"; import type { Settings } from "../config/settings"; import MODEL_PRIO from "../priority.json" with { type: "json" }; import commitSystemPrompt from "../prompts/system/commit-message-system.md" with { type: "text" }; @@ -51,7 +51,7 @@ function getSmolModelCandidates( candidates.push({ model, thinkingLevel }); }; - const matchPreferences = { usageOrder: settings.getStorage()?.getModelUsageOrder() }; + const matchPreferences = getModelMatchPreferences(settings); const configuredSmol = resolveModelRoleValue(settings.getModelRole("smol"), availableModels, { settings, matchPreferences, diff --git a/packages/coding-agent/test/model-resolver.test.ts b/packages/coding-agent/test/model-resolver.test.ts index 62346134a..b9ca3fc49 100644 --- a/packages/coding-agent/test/model-resolver.test.ts +++ b/packages/coding-agent/test/model-resolver.test.ts @@ -422,7 +422,7 @@ describe("parseModelPattern", () => { expect(result.model?.provider).toBe("kimi-code"); }); - test("falls back to deprioritizing openrouter when no usage data", () => { + test("prefers first-party providers over OpenRouter when no usage data exists", () => { const result = parseModelPattern("k2.5", allModels, { usageOrder: [] }); expect(result.model?.provider).toBe("kimi-code"); }); diff --git a/packages/coding-agent/test/sdk-model-selection.test.ts b/packages/coding-agent/test/sdk-model-selection.test.ts index 89f072394..c3d7e2281 100644 --- a/packages/coding-agent/test/sdk-model-selection.test.ts +++ b/packages/coding-agent/test/sdk-model-selection.test.ts @@ -165,6 +165,50 @@ describe("createAgentSession deferred model pattern resolution", () => { } }); + test("prefers the provider default over catalog order in the startup fallback", async () => { + // Regression: with an Anthropic key but no configured `default` role and no + // session/CLI model, the step-4 startup fallback used to pick the first + // anthropic model in models.json catalog order (claude-3-5-sonnet-20240620) + // instead of the provider's configured default from DEFAULT_MODEL_PER_PROVIDER + // (claude-opus-4-6). + const providerDefault = getBundledModel("anthropic", "claude-opus-4-6"); + const catalogFirst = getBundledModel("anthropic", "claude-3-5-sonnet-20240620"); + if (!providerDefault || !catalogFirst) { + throw new Error("Expected bundled anthropic models for fallback regression"); + } + + const authStorage = await AuthStorage.create(path.join(tempDir, "fallbackauth.db")); + authStoragesToClose.push(authStorage); + authStorage.setRuntimeApiKey("anthropic", "test-key"); + const modelRegistry = new ModelRegistry(authStorage, path.join(tempDir, "models.yml")); + // No `default` model role configured: forces the step-4 startup fallback. + const settings = Settings.isolated(); + + const { session } = await createAgentSession({ + cwd: tempDir, + agentDir: tempDir, + authStorage, + modelRegistry, + settings, + sessionManager: SessionManager.inMemory(), + disableExtensionDiscovery: true, + skills: [], + contextFiles: [], + promptTemplates: [], + slashCommands: [], + enableMCP: false, + enableLsp: false, + }); + + try { + expect(session.model?.provider).toBe("anthropic"); + expect(session.model?.id).toBe(providerDefault.id); + expect(session.model?.id).not.toBe(catalogFirst.id); + } finally { + await session.dispose(); + } + }); + test("restores role model from extension provider after startup resume", async () => { const defaultModel = getBundledModel("anthropic", "claude-sonnet-4-5"); if (!defaultModel) {