feat: skip model resolution for delegated tasks when provider cache not yet created

Before provider cache exists (first run), resolveModelForDelegateTask now
returns undefined instead of guessing a model. This lets OpenCode use its
system default model when no model is specified in the prompt body.

User-specified model overrides still take priority regardless of cache state.
This commit is contained in:
YeonGyu-Kim
2026-03-12 17:27:21 +09:00
parent 62883d753f
commit 81301a6071
2 changed files with 137 additions and 0 deletions

View File

@@ -0,0 +1,130 @@
declare const require: (name: string) => any
const { describe, test, expect, beforeEach, afterEach, spyOn, mock } = require("bun:test")
import { resolveModelForDelegateTask } from "./model-selection"
import * as connectedProvidersCache from "../../shared/connected-providers-cache"
describe("resolveModelForDelegateTask", () => {
let hasConnectedProvidersSpy: ReturnType<typeof spyOn> | undefined
let hasProviderModelsSpy: ReturnType<typeof spyOn> | undefined
beforeEach(() => {
mock.restore()
})
afterEach(() => {
hasConnectedProvidersSpy?.mockRestore()
hasProviderModelsSpy?.mockRestore()
})
describe("#given no provider cache exists (pre-cache scenario)", () => {
beforeEach(() => {
hasConnectedProvidersSpy = spyOn(connectedProvidersCache, "hasConnectedProvidersCache").mockReturnValue(false)
hasProviderModelsSpy = spyOn(connectedProvidersCache, "hasProviderModelsCache").mockReturnValue(false)
})
describe("#when availableModels is empty and no user model override", () => {
test("#then returns undefined to let OpenCode use system default", () => {
const result = resolveModelForDelegateTask({
categoryDefaultModel: "anthropic/claude-sonnet-4-6",
fallbackChain: [
{ providers: ["anthropic"], model: "claude-sonnet-4-6" },
],
availableModels: new Set(),
systemDefaultModel: "anthropic/claude-sonnet-4-6",
})
expect(result).toBeUndefined()
})
})
describe("#when user explicitly set a model override", () => {
test("#then returns the user model regardless of cache state", () => {
const result = resolveModelForDelegateTask({
userModel: "openai/gpt-5.4",
categoryDefaultModel: "anthropic/claude-sonnet-4-6",
fallbackChain: [
{ providers: ["anthropic"], model: "claude-sonnet-4-6" },
],
availableModels: new Set(),
systemDefaultModel: "anthropic/claude-sonnet-4-6",
})
expect(result).toEqual({ model: "openai/gpt-5.4" })
})
})
describe("#when user set fallback_models but no cache exists", () => {
test("#then returns undefined (skip fallback resolution without cache)", () => {
const result = resolveModelForDelegateTask({
userFallbackModels: ["openai/gpt-5.4", "google/gemini-3.1-pro"],
categoryDefaultModel: "anthropic/claude-sonnet-4-6",
fallbackChain: [
{ providers: ["anthropic"], model: "claude-sonnet-4-6" },
],
availableModels: new Set(),
})
expect(result).toBeUndefined()
})
})
})
describe("#given provider cache exists", () => {
beforeEach(() => {
hasConnectedProvidersSpy = spyOn(connectedProvidersCache, "hasConnectedProvidersCache").mockReturnValue(true)
hasProviderModelsSpy = spyOn(connectedProvidersCache, "hasProviderModelsCache").mockReturnValue(true)
})
describe("#when availableModels is empty (cache exists but empty)", () => {
test("#then falls through to category default model (existing behavior)", () => {
const result = resolveModelForDelegateTask({
categoryDefaultModel: "anthropic/claude-sonnet-4-6",
fallbackChain: [
{ providers: ["anthropic"], model: "claude-sonnet-4-6" },
],
availableModels: new Set(),
systemDefaultModel: "anthropic/claude-sonnet-4-6",
})
expect(result).toBeDefined()
expect(result!.model).toBe("anthropic/claude-sonnet-4-6")
})
})
describe("#when availableModels has entries and category default matches", () => {
test("#then resolves via fuzzy match (existing behavior)", () => {
const result = resolveModelForDelegateTask({
categoryDefaultModel: "anthropic/claude-sonnet-4-6",
fallbackChain: [
{ providers: ["anthropic"], model: "claude-sonnet-4-6" },
],
availableModels: new Set(["anthropic/claude-sonnet-4-6"]),
})
expect(result).toBeDefined()
expect(result!.model).toBe("anthropic/claude-sonnet-4-6")
})
})
})
describe("#given only connected providers cache exists (no provider-models cache)", () => {
beforeEach(() => {
hasConnectedProvidersSpy = spyOn(connectedProvidersCache, "hasConnectedProvidersCache").mockReturnValue(true)
hasProviderModelsSpy = spyOn(connectedProvidersCache, "hasProviderModelsCache").mockReturnValue(false)
})
describe("#when availableModels is empty", () => {
test("#then falls through to existing resolution (cache partially ready)", () => {
const result = resolveModelForDelegateTask({
categoryDefaultModel: "anthropic/claude-sonnet-4-6",
fallbackChain: [
{ providers: ["anthropic"], model: "claude-sonnet-4-6" },
],
availableModels: new Set(),
})
expect(result).toBeDefined()
})
})
})
})

View File

@@ -2,6 +2,7 @@ import type { FallbackEntry } from "../../shared/model-requirements"
import { normalizeModel } from "../../shared/model-normalization"
import { fuzzyMatchModel } from "../../shared/model-availability"
import { transformModelForProvider } from "../../shared/provider-model-id-transform"
import { hasConnectedProvidersCache, hasProviderModelsCache } from "../../shared/connected-providers-cache"
function isExplicitHighModel(model: string): boolean {
return /(?:^|\/)[^/]+-high$/.test(model)
@@ -25,6 +26,12 @@ export function resolveModelForDelegateTask(input: {
return { model: userModel }
}
// Before provider cache is created (first run), skip model resolution entirely.
// OpenCode will use its system default model when no model is specified in the prompt.
if (input.availableModels.size === 0 && !hasProviderModelsCache() && !hasConnectedProvidersCache()) {
return undefined
}
const categoryDefault = normalizeModel(input.categoryDefaultModel)
const explicitHighBaseModel = categoryDefault ? getExplicitHighBaseModel(categoryDefault) : null
const explicitHighModel = explicitHighBaseModel ? categoryDefault : undefined