fix(model-resolver): respect UI model selection in agent initialization (#1158)

- Add uiSelectedModel parameter to resolveModelWithFallback()
- Update model resolution priority: UI Selection → Config Override → Fallback → System Default
- Pass config.model as uiSelectedModel in createBuiltinAgents()
- Fix ProviderModelNotFoundError when model is unset in config but selected in UI
This commit is contained in:
SUHO LEE 2026-01-29 09:30:35 +09:00 committed by GitHub
parent c7455708f8
commit 0c3fbd724b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 103 additions and 14 deletions

View File

@ -149,7 +149,8 @@ export async function createBuiltinAgents(
gitMasterConfig?: GitMasterConfig,
discoveredSkills: LoadedSkill[] = [],
client?: any,
browserProvider?: BrowserAutomationProvider
browserProvider?: BrowserAutomationProvider,
uiSelectedModel?: string
): Promise<Record<string, AgentConfig>> {
const connectedProviders = readConnectedProvidersCache()
const availableModels = client
@ -198,6 +199,7 @@ export async function createBuiltinAgents(
const requirement = AGENT_MODEL_REQUIREMENTS[agentName]
const resolution = resolveModelWithFallback({
uiSelectedModel,
userModel: override?.model,
fallbackChain: requirement?.fallbackChain,
availableModels,
@ -241,6 +243,7 @@ export async function createBuiltinAgents(
const sisyphusRequirement = AGENT_MODEL_REQUIREMENTS["sisyphus"]
const sisyphusResolution = resolveModelWithFallback({
uiSelectedModel,
userModel: sisyphusOverride?.model,
fallbackChain: sisyphusRequirement?.fallbackChain,
availableModels,
@ -282,6 +285,7 @@ export async function createBuiltinAgents(
const atlasRequirement = AGENT_MODEL_REQUIREMENTS["atlas"]
const atlasResolution = resolveModelWithFallback({
uiSelectedModel,
userModel: orchestratorOverride?.model,
fallbackChain: atlasRequirement?.fallbackChain,
availableModels,

View File

@ -133,16 +133,20 @@ export function createConfigHandler(deps: ConfigHandlerDeps) {
];
const browserProvider = pluginConfig.browser_automation_engine?.provider ?? "playwright";
// config.model represents the currently active model in OpenCode (including UI selection)
// Pass it as uiSelectedModel so it takes highest priority in model resolution
const currentModel = config.model as string | undefined;
const builtinAgents = await createBuiltinAgents(
migratedDisabledAgents,
pluginConfig.agents,
ctx.directory,
config.model as string | undefined,
undefined, // systemDefaultModel - let fallback chain handle this
pluginConfig.categories,
pluginConfig.git_master,
allDiscoveredSkills,
ctx.client,
browserProvider
browserProvider,
currentModel // uiSelectedModel - takes highest priority
);
// Claude Code agents: Do NOT apply permission migration
@ -225,7 +229,6 @@ export function createConfigHandler(deps: ConfigHandlerDeps) {
pluginConfig.agents?.["prometheus"] as
| (Record<string, unknown> & { category?: string; model?: string; variant?: string })
| undefined;
const defaultModel = config.model as string | undefined;
const categoryConfig = prometheusOverride?.category
? resolveCategoryConfig(
@ -241,10 +244,11 @@ export function createConfigHandler(deps: ConfigHandlerDeps) {
: new Set<string>();
const modelResolution = resolveModelWithFallback({
uiSelectedModel: currentModel,
userModel: prometheusOverride?.model ?? categoryConfig?.model,
fallbackChain: prometheusRequirement?.fallbackChain,
availableModels,
systemDefaultModel: defaultModel ?? "",
systemDefaultModel: undefined, // let fallback chain handle this
});
const resolvedModel = modelResolution?.model;
const resolvedVariant = modelResolution?.variant;

View File

@ -113,7 +113,80 @@ describe("resolveModelWithFallback", () => {
logSpy.mockRestore()
})
describe("Step 1: Override", () => {
describe("Step 1: UI Selection (highest priority)", () => {
test("returns uiSelectedModel with override source when provided", () => {
// #given
const input: ExtendedModelResolutionInput = {
uiSelectedModel: "opencode/glm-4.7-free",
userModel: "anthropic/claude-opus-4-5",
fallbackChain: [
{ providers: ["anthropic", "github-copilot"], model: "claude-opus-4-5" },
],
availableModels: new Set(["anthropic/claude-opus-4-5", "github-copilot/claude-opus-4-5-preview"]),
systemDefaultModel: "google/gemini-3-pro",
}
// #when
const result = resolveModelWithFallback(input)
// #then
expect(result!.model).toBe("opencode/glm-4.7-free")
expect(result!.source).toBe("override")
expect(logSpy).toHaveBeenCalledWith("Model resolved via UI selection", { model: "opencode/glm-4.7-free" })
})
test("UI selection takes priority over config override", () => {
// #given
const input: ExtendedModelResolutionInput = {
uiSelectedModel: "opencode/glm-4.7-free",
userModel: "anthropic/claude-opus-4-5",
availableModels: new Set(["anthropic/claude-opus-4-5"]),
systemDefaultModel: "google/gemini-3-pro",
}
// #when
const result = resolveModelWithFallback(input)
// #then
expect(result!.model).toBe("opencode/glm-4.7-free")
expect(result!.source).toBe("override")
})
test("whitespace-only uiSelectedModel is treated as not provided", () => {
// #given
const input: ExtendedModelResolutionInput = {
uiSelectedModel: " ",
userModel: "anthropic/claude-opus-4-5",
availableModels: new Set(["anthropic/claude-opus-4-5"]),
systemDefaultModel: "google/gemini-3-pro",
}
// #when
const result = resolveModelWithFallback(input)
// #then
expect(result!.model).toBe("anthropic/claude-opus-4-5")
expect(logSpy).toHaveBeenCalledWith("Model resolved via config override", { model: "anthropic/claude-opus-4-5" })
})
test("empty string uiSelectedModel falls through to config override", () => {
// #given
const input: ExtendedModelResolutionInput = {
uiSelectedModel: "",
userModel: "anthropic/claude-opus-4-5",
availableModels: new Set(["anthropic/claude-opus-4-5"]),
systemDefaultModel: "google/gemini-3-pro",
}
// #when
const result = resolveModelWithFallback(input)
// #then
expect(result!.model).toBe("anthropic/claude-opus-4-5")
})
})
describe("Step 2: Config Override", () => {
test("returns userModel with override source when userModel is provided", () => {
// #given
const input: ExtendedModelResolutionInput = {
@ -131,7 +204,7 @@ describe("resolveModelWithFallback", () => {
// #then
expect(result!.model).toBe("anthropic/claude-opus-4-5")
expect(result!.source).toBe("override")
expect(logSpy).toHaveBeenCalledWith("Model resolved via override", { model: "anthropic/claude-opus-4-5" })
expect(logSpy).toHaveBeenCalledWith("Model resolved via config override", { model: "anthropic/claude-opus-4-5" })
})
test("override takes priority even if model not in availableModels", () => {
@ -190,7 +263,7 @@ describe("resolveModelWithFallback", () => {
})
})
describe("Step 2: Provider fallback chain", () => {
describe("Step 3: Provider fallback chain", () => {
test("tries providers in order within entry and returns first match", () => {
// #given
const input: ExtendedModelResolutionInput = {
@ -317,7 +390,7 @@ describe("resolveModelWithFallback", () => {
})
})
describe("Step 3: System default fallback (no availability match)", () => {
describe("Step 4: System default fallback (no availability match)", () => {
test("returns system default when no availability match found in fallback chain", () => {
// #given
const input: ExtendedModelResolutionInput = {

View File

@ -21,6 +21,7 @@ export type ModelResolutionResult = {
}
export type ExtendedModelResolutionInput = {
uiSelectedModel?: string
userModel?: string
fallbackChain?: FallbackEntry[]
availableModels: Set<string>
@ -43,16 +44,23 @@ export function resolveModel(input: ModelResolutionInput): string | undefined {
export function resolveModelWithFallback(
input: ExtendedModelResolutionInput,
): ModelResolutionResult | undefined {
const { userModel, fallbackChain, availableModels, systemDefaultModel } = input
const { uiSelectedModel, userModel, fallbackChain, availableModels, systemDefaultModel } = input
// Step 1: Override
// Step 1: UI Selection (highest priority - respects user's model choice in OpenCode UI)
const normalizedUiModel = normalizeModel(uiSelectedModel)
if (normalizedUiModel) {
log("Model resolved via UI selection", { model: normalizedUiModel })
return { model: normalizedUiModel, source: "override" }
}
// Step 2: Config Override (from oh-my-opencode.json)
const normalizedUserModel = normalizeModel(userModel)
if (normalizedUserModel) {
log("Model resolved via override", { model: normalizedUserModel })
log("Model resolved via config override", { model: normalizedUserModel })
return { model: normalizedUserModel, source: "override" }
}
// Step 2: Provider fallback chain (with availability check)
// Step 3: Provider fallback chain (with availability check)
if (fallbackChain && fallbackChain.length > 0) {
if (availableModels.size === 0) {
const connectedProviders = readConnectedProvidersCache()
@ -91,7 +99,7 @@ export function resolveModelWithFallback(
log("No available model found in fallback chain, falling through to system default")
}
// Step 3: System default (if provided)
// Step 4: System default (if provided)
if (systemDefaultModel === undefined) {
log("No model resolved - systemDefaultModel not configured")
return undefined