fix(model-resolver): skip fallback chain when no cache exists
When no provider cache exists, skip the fallback chain entirely and let OpenCode use Provider.defaultModel() as the final fallback. This prevents incorrect model selection when the plugin loads before providers connect. - Remove forced first-entry fallback when no cache - Add log messages for cache miss scenarios - Update tests for new behavior
This commit is contained in:
parent
76f8c500cb
commit
8a9d966a3d
@ -1,7 +1,8 @@
|
|||||||
import { describe, test, expect, beforeEach } from "bun:test"
|
import { describe, test, expect, beforeEach, spyOn, afterEach } from "bun:test"
|
||||||
import { createBuiltinAgents } from "./utils"
|
import { createBuiltinAgents } from "./utils"
|
||||||
import type { AgentConfig } from "@opencode-ai/sdk"
|
import type { AgentConfig } from "@opencode-ai/sdk"
|
||||||
import { clearSkillCache } from "../features/opencode-skill-loader/skill-content"
|
import { clearSkillCache } from "../features/opencode-skill-loader/skill-content"
|
||||||
|
import * as connectedProvidersCache from "../shared/connected-providers-cache"
|
||||||
|
|
||||||
const TEST_DEFAULT_MODEL = "anthropic/claude-opus-4-5"
|
const TEST_DEFAULT_MODEL = "anthropic/claude-opus-4-5"
|
||||||
|
|
||||||
@ -46,17 +47,32 @@ describe("createBuiltinAgents with model overrides", () => {
|
|||||||
expect(agents.sisyphus.reasoningEffort).toBeUndefined()
|
expect(agents.sisyphus.reasoningEffort).toBeUndefined()
|
||||||
})
|
})
|
||||||
|
|
||||||
test("Oracle uses first fallback entry when no availableModels provided (no cache scenario)", async () => {
|
test("Oracle uses connected provider when no availableModels but connected cache exists", async () => {
|
||||||
// #given - no available models simulates CI without model cache
|
// #given - connected providers cache exists with openai
|
||||||
|
const cacheSpy = spyOn(connectedProvidersCache, "readConnectedProvidersCache").mockReturnValue(["openai"])
|
||||||
|
|
||||||
// #when
|
// #when
|
||||||
const agents = await createBuiltinAgents([], {}, undefined, TEST_DEFAULT_MODEL)
|
const agents = await createBuiltinAgents([], {}, undefined, TEST_DEFAULT_MODEL)
|
||||||
|
|
||||||
// #then - uses first fallback entry (openai/gpt-5.2) instead of system default
|
// #then - uses openai from connected cache
|
||||||
expect(agents.oracle.model).toBe("openai/gpt-5.2")
|
expect(agents.oracle.model).toBe("openai/gpt-5.2")
|
||||||
expect(agents.oracle.reasoningEffort).toBe("medium")
|
expect(agents.oracle.reasoningEffort).toBe("medium")
|
||||||
expect(agents.oracle.textVerbosity).toBe("high")
|
expect(agents.oracle.textVerbosity).toBe("high")
|
||||||
expect(agents.oracle.thinking).toBeUndefined()
|
expect(agents.oracle.thinking).toBeUndefined()
|
||||||
|
cacheSpy.mockRestore()
|
||||||
|
})
|
||||||
|
|
||||||
|
test("Oracle created without model field when no cache exists (first run scenario)", async () => {
|
||||||
|
// #given - no cache at all (first run)
|
||||||
|
const cacheSpy = spyOn(connectedProvidersCache, "readConnectedProvidersCache").mockReturnValue(null)
|
||||||
|
|
||||||
|
// #when
|
||||||
|
const agents = await createBuiltinAgents([], {}, undefined, TEST_DEFAULT_MODEL)
|
||||||
|
|
||||||
|
// #then - oracle should be created with system default model (fallback to systemDefaultModel)
|
||||||
|
expect(agents.oracle).toBeDefined()
|
||||||
|
expect(agents.oracle.model).toBe(TEST_DEFAULT_MODEL)
|
||||||
|
cacheSpy.mockRestore()
|
||||||
})
|
})
|
||||||
|
|
||||||
test("Oracle with GPT model override has reasoningEffort, no thinking", async () => {
|
test("Oracle with GPT model override has reasoningEffort, no thinking", async () => {
|
||||||
@ -107,26 +123,42 @@ describe("createBuiltinAgents with model overrides", () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
describe("createBuiltinAgents without systemDefaultModel", () => {
|
describe("createBuiltinAgents without systemDefaultModel", () => {
|
||||||
test("creates agents successfully without systemDefaultModel", async () => {
|
test("creates agents with connected provider when cache exists", async () => {
|
||||||
// #given - no systemDefaultModel provided
|
// #given - connected providers cache exists
|
||||||
|
const cacheSpy = spyOn(connectedProvidersCache, "readConnectedProvidersCache").mockReturnValue(["openai"])
|
||||||
|
|
||||||
// #when
|
// #when
|
||||||
const agents = await createBuiltinAgents([], {}, undefined, undefined)
|
const agents = await createBuiltinAgents([], {}, undefined, undefined)
|
||||||
|
|
||||||
// #then - agents should still be created using fallback chain
|
// #then - agents should use connected provider from fallback chain
|
||||||
expect(agents.oracle).toBeDefined()
|
expect(agents.oracle).toBeDefined()
|
||||||
expect(agents.oracle.model).toBe("openai/gpt-5.2")
|
expect(agents.oracle.model).toBe("openai/gpt-5.2")
|
||||||
|
cacheSpy.mockRestore()
|
||||||
})
|
})
|
||||||
|
|
||||||
test("sisyphus uses fallback chain when systemDefaultModel undefined", async () => {
|
test("agents NOT created when no cache and no systemDefaultModel (first run without defaults)", async () => {
|
||||||
// #given - no systemDefaultModel
|
// #given - no cache and no system default
|
||||||
|
const cacheSpy = spyOn(connectedProvidersCache, "readConnectedProvidersCache").mockReturnValue(null)
|
||||||
|
|
||||||
// #when
|
// #when
|
||||||
const agents = await createBuiltinAgents([], {}, undefined, undefined)
|
const agents = await createBuiltinAgents([], {}, undefined, undefined)
|
||||||
|
|
||||||
// #then - sisyphus should use its fallback chain
|
// #then - oracle should NOT be created (resolveModelWithFallback returns undefined)
|
||||||
|
expect(agents.oracle).toBeUndefined()
|
||||||
|
cacheSpy.mockRestore()
|
||||||
|
})
|
||||||
|
|
||||||
|
test("sisyphus uses connected provider when cache exists", async () => {
|
||||||
|
// #given - connected providers cache exists with anthropic
|
||||||
|
const cacheSpy = spyOn(connectedProvidersCache, "readConnectedProvidersCache").mockReturnValue(["anthropic"])
|
||||||
|
|
||||||
|
// #when
|
||||||
|
const agents = await createBuiltinAgents([], {}, undefined, undefined)
|
||||||
|
|
||||||
|
// #then - sisyphus should use anthropic from connected cache
|
||||||
expect(agents.sisyphus).toBeDefined()
|
expect(agents.sisyphus).toBeDefined()
|
||||||
expect(agents.sisyphus.model).toBe("anthropic/claude-opus-4-5")
|
expect(agents.sisyphus.model).toBe("anthropic/claude-opus-4-5")
|
||||||
|
cacheSpy.mockRestore()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
import { describe, expect, test, spyOn, beforeEach, afterEach } from "bun:test"
|
import { describe, expect, test, spyOn, beforeEach, afterEach, mock } from "bun:test"
|
||||||
import { resolveModel, resolveModelWithFallback, type ModelResolutionInput, type ExtendedModelResolutionInput, type ModelResolutionResult, type ModelSource } from "./model-resolver"
|
import { resolveModel, resolveModelWithFallback, type ModelResolutionInput, type ExtendedModelResolutionInput, type ModelResolutionResult, type ModelSource } from "./model-resolver"
|
||||||
import * as logger from "./logger"
|
import * as logger from "./logger"
|
||||||
|
import * as connectedProvidersCache from "./connected-providers-cache"
|
||||||
|
|
||||||
describe("resolveModel", () => {
|
describe("resolveModel", () => {
|
||||||
describe("priority chain", () => {
|
describe("priority chain", () => {
|
||||||
@ -336,8 +337,48 @@ describe("resolveModelWithFallback", () => {
|
|||||||
expect(logSpy).toHaveBeenCalledWith("No available model found in fallback chain, falling through to system default")
|
expect(logSpy).toHaveBeenCalledWith("No available model found in fallback chain, falling through to system default")
|
||||||
})
|
})
|
||||||
|
|
||||||
test("uses first fallback entry when availableModels is empty (no cache scenario)", () => {
|
test("returns undefined when availableModels empty and no connected providers cache exists", () => {
|
||||||
// #given - empty availableModels simulates CI environment without model cache
|
// #given - both model cache and connected-providers cache are missing (first run)
|
||||||
|
const cacheSpy = spyOn(connectedProvidersCache, "readConnectedProvidersCache").mockReturnValue(null)
|
||||||
|
const input: ExtendedModelResolutionInput = {
|
||||||
|
fallbackChain: [
|
||||||
|
{ providers: ["anthropic"], model: "claude-opus-4-5" },
|
||||||
|
],
|
||||||
|
availableModels: new Set(),
|
||||||
|
systemDefaultModel: undefined, // no system default configured
|
||||||
|
}
|
||||||
|
|
||||||
|
// #when
|
||||||
|
const result = resolveModelWithFallback(input)
|
||||||
|
|
||||||
|
// #then - should return undefined to let OpenCode use Provider.defaultModel()
|
||||||
|
expect(result).toBeUndefined()
|
||||||
|
cacheSpy.mockRestore()
|
||||||
|
})
|
||||||
|
|
||||||
|
test("uses connected provider when availableModels empty but connected providers cache exists", () => {
|
||||||
|
// #given - model cache missing but connected-providers cache exists
|
||||||
|
const cacheSpy = spyOn(connectedProvidersCache, "readConnectedProvidersCache").mockReturnValue(["openai", "google"])
|
||||||
|
const input: ExtendedModelResolutionInput = {
|
||||||
|
fallbackChain: [
|
||||||
|
{ providers: ["anthropic", "openai"], model: "claude-opus-4-5" },
|
||||||
|
],
|
||||||
|
availableModels: new Set(),
|
||||||
|
systemDefaultModel: "google/gemini-3-pro",
|
||||||
|
}
|
||||||
|
|
||||||
|
// #when
|
||||||
|
const result = resolveModelWithFallback(input)
|
||||||
|
|
||||||
|
// #then - should use openai (second provider) since anthropic not in connected cache
|
||||||
|
expect(result!.model).toBe("openai/claude-opus-4-5")
|
||||||
|
expect(result!.source).toBe("provider-fallback")
|
||||||
|
cacheSpy.mockRestore()
|
||||||
|
})
|
||||||
|
|
||||||
|
test("falls through to system default when no cache and systemDefaultModel is provided", () => {
|
||||||
|
// #given - no cache but system default is configured
|
||||||
|
const cacheSpy = spyOn(connectedProvidersCache, "readConnectedProvidersCache").mockReturnValue(null)
|
||||||
const input: ExtendedModelResolutionInput = {
|
const input: ExtendedModelResolutionInput = {
|
||||||
fallbackChain: [
|
fallbackChain: [
|
||||||
{ providers: ["anthropic"], model: "claude-opus-4-5" },
|
{ providers: ["anthropic"], model: "claude-opus-4-5" },
|
||||||
@ -349,9 +390,10 @@ describe("resolveModelWithFallback", () => {
|
|||||||
// #when
|
// #when
|
||||||
const result = resolveModelWithFallback(input)
|
const result = resolveModelWithFallback(input)
|
||||||
|
|
||||||
// #then - should use first fallback entry, not system default
|
// #then - should fall through to system default
|
||||||
expect(result!.model).toBe("anthropic/claude-opus-4-5")
|
expect(result!.model).toBe("google/gemini-3-pro")
|
||||||
expect(result!.source).toBe("provider-fallback")
|
expect(result!.source).toBe("system-default")
|
||||||
|
cacheSpy.mockRestore()
|
||||||
})
|
})
|
||||||
|
|
||||||
test("returns system default when fallbackChain is not provided", () => {
|
test("returns system default when fallbackChain is not provided", () => {
|
||||||
|
|||||||
@ -58,25 +58,26 @@ export function resolveModelWithFallback(
|
|||||||
const connectedProviders = readConnectedProvidersCache()
|
const connectedProviders = readConnectedProvidersCache()
|
||||||
const connectedSet = connectedProviders ? new Set(connectedProviders) : null
|
const connectedSet = connectedProviders ? new Set(connectedProviders) : null
|
||||||
|
|
||||||
for (const entry of fallbackChain) {
|
// When no cache exists at all, skip fallback chain and fall through to system default
|
||||||
for (const provider of entry.providers) {
|
// This allows OpenCode to use Provider.defaultModel() as the final fallback
|
||||||
if (connectedSet === null || connectedSet.has(provider)) {
|
if (connectedSet === null) {
|
||||||
const model = `${provider}/${entry.model}`
|
log("No cache available, skipping fallback chain to use system default")
|
||||||
log("Model resolved via fallback chain (no model cache, using connected provider)", {
|
} else {
|
||||||
provider,
|
for (const entry of fallbackChain) {
|
||||||
model: entry.model,
|
for (const provider of entry.providers) {
|
||||||
variant: entry.variant,
|
if (connectedSet.has(provider)) {
|
||||||
hasConnectedCache: connectedSet !== null
|
const model = `${provider}/${entry.model}`
|
||||||
})
|
log("Model resolved via fallback chain (no model cache, using connected provider)", {
|
||||||
return { model, source: "provider-fallback", variant: entry.variant }
|
provider,
|
||||||
|
model: entry.model,
|
||||||
|
variant: entry.variant,
|
||||||
|
})
|
||||||
|
return { model, source: "provider-fallback", variant: entry.variant }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
log("No matching provider in connected cache, falling through to system default")
|
||||||
}
|
}
|
||||||
const firstEntry = fallbackChain[0]
|
|
||||||
const firstProvider = firstEntry.providers[0]
|
|
||||||
const model = `${firstProvider}/${firstEntry.model}`
|
|
||||||
log("Model resolved via fallback chain (no cache at all, using first entry)", { provider: firstProvider, model: firstEntry.model, variant: firstEntry.variant })
|
|
||||||
return { model, source: "provider-fallback", variant: firstEntry.variant }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const entry of fallbackChain) {
|
for (const entry of fallbackChain) {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user