feat: make systemDefaultModel optional for OpenCode fallback (#1136)

- Remove mandatory model requirement from plugin initialization
- Allow OpenCode to use its built-in model fallback when user doesn't specify
- Update model-resolver to handle undefined systemDefaultModel
- Remove throw errors in config-handler, utils, atlas, delegate-task
- Add tests for optional model scenarios

Closes #1129

Co-authored-by: justsisyphus <justsisyphus@users.noreply.github.com>
This commit is contained in:
justsisyphus
2026-01-26 17:01:08 +09:00
committed by GitHub
parent c9b86b7815
commit 3ee519c7b0
9 changed files with 246 additions and 190 deletions
+105 -35
View File
@@ -128,8 +128,8 @@ describe("resolveModelWithFallback", () => {
const result = resolveModelWithFallback(input)
// #then
expect(result.model).toBe("anthropic/claude-opus-4-5")
expect(result.source).toBe("override")
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" })
})
@@ -148,8 +148,8 @@ describe("resolveModelWithFallback", () => {
const result = resolveModelWithFallback(input)
// #then
expect(result.model).toBe("custom/my-model")
expect(result.source).toBe("override")
expect(result!.model).toBe("custom/my-model")
expect(result!.source).toBe("override")
})
test("whitespace-only userModel is treated as not provided", () => {
@@ -167,7 +167,7 @@ describe("resolveModelWithFallback", () => {
const result = resolveModelWithFallback(input)
// #then
expect(result.source).not.toBe("override")
expect(result!.source).not.toBe("override")
})
test("empty string userModel is treated as not provided", () => {
@@ -185,7 +185,7 @@ describe("resolveModelWithFallback", () => {
const result = resolveModelWithFallback(input)
// #then
expect(result.source).not.toBe("override")
expect(result!.source).not.toBe("override")
})
})
@@ -204,8 +204,8 @@ describe("resolveModelWithFallback", () => {
const result = resolveModelWithFallback(input)
// #then
expect(result.model).toBe("github-copilot/claude-opus-4-5-preview")
expect(result.source).toBe("provider-fallback")
expect(result!.model).toBe("github-copilot/claude-opus-4-5-preview")
expect(result!.source).toBe("provider-fallback")
expect(logSpy).toHaveBeenCalledWith("Model resolved via fallback chain (availability confirmed)", {
provider: "github-copilot",
model: "claude-opus-4-5",
@@ -228,8 +228,8 @@ describe("resolveModelWithFallback", () => {
const result = resolveModelWithFallback(input)
// #then
expect(result.model).toBe("openai/gpt-5.2")
expect(result.source).toBe("provider-fallback")
expect(result!.model).toBe("openai/gpt-5.2")
expect(result!.source).toBe("provider-fallback")
})
test("tries next provider when first provider has no match", () => {
@@ -246,8 +246,8 @@ describe("resolveModelWithFallback", () => {
const result = resolveModelWithFallback(input)
// #then
expect(result.model).toBe("opencode/gpt-5-nano")
expect(result.source).toBe("provider-fallback")
expect(result!.model).toBe("opencode/gpt-5-nano")
expect(result!.source).toBe("provider-fallback")
})
test("uses fuzzy matching within provider", () => {
@@ -264,8 +264,8 @@ describe("resolveModelWithFallback", () => {
const result = resolveModelWithFallback(input)
// #then
expect(result.model).toBe("anthropic/claude-opus-4-5")
expect(result.source).toBe("provider-fallback")
expect(result!.model).toBe("anthropic/claude-opus-4-5")
expect(result!.source).toBe("provider-fallback")
})
test("skips fallback chain when not provided", () => {
@@ -279,7 +279,7 @@ describe("resolveModelWithFallback", () => {
const result = resolveModelWithFallback(input)
// #then
expect(result.source).toBe("system-default")
expect(result!.source).toBe("system-default")
})
test("skips fallback chain when empty", () => {
@@ -294,7 +294,7 @@ describe("resolveModelWithFallback", () => {
const result = resolveModelWithFallback(input)
// #then
expect(result.source).toBe("system-default")
expect(result!.source).toBe("system-default")
})
test("case-insensitive fuzzy matching", () => {
@@ -311,8 +311,8 @@ describe("resolveModelWithFallback", () => {
const result = resolveModelWithFallback(input)
// #then
expect(result.model).toBe("anthropic/claude-opus-4-5")
expect(result.source).toBe("provider-fallback")
expect(result!.model).toBe("anthropic/claude-opus-4-5")
expect(result!.source).toBe("provider-fallback")
})
})
@@ -331,8 +331,8 @@ describe("resolveModelWithFallback", () => {
const result = resolveModelWithFallback(input)
// #then
expect(result.model).toBe("google/gemini-3-pro")
expect(result.source).toBe("system-default")
expect(result!.model).toBe("google/gemini-3-pro")
expect(result!.source).toBe("system-default")
expect(logSpy).toHaveBeenCalledWith("No available model found in fallback chain, falling through to system default")
})
@@ -350,8 +350,8 @@ describe("resolveModelWithFallback", () => {
const result = resolveModelWithFallback(input)
// #then - should use first fallback entry, not system default
expect(result.model).toBe("anthropic/claude-opus-4-5")
expect(result.source).toBe("provider-fallback")
expect(result!.model).toBe("anthropic/claude-opus-4-5")
expect(result!.source).toBe("provider-fallback")
})
test("returns system default when fallbackChain is not provided", () => {
@@ -365,8 +365,8 @@ describe("resolveModelWithFallback", () => {
const result = resolveModelWithFallback(input)
// #then
expect(result.model).toBe("google/gemini-3-pro")
expect(result.source).toBe("system-default")
expect(result!.model).toBe("google/gemini-3-pro")
expect(result!.source).toBe("system-default")
})
})
@@ -386,8 +386,8 @@ describe("resolveModelWithFallback", () => {
})
// #then
expect(result.model).toBe("anthropic/claude-opus-4-5")
expect(result.source).toBe("provider-fallback")
expect(result!.model).toBe("anthropic/claude-opus-4-5")
expect(result!.source).toBe("provider-fallback")
})
test("tries all providers in first entry before moving to second entry", () => {
@@ -405,8 +405,8 @@ describe("resolveModelWithFallback", () => {
})
// #then
expect(result.model).toBe("google/gemini-3-pro")
expect(result.source).toBe("provider-fallback")
expect(result!.model).toBe("google/gemini-3-pro")
expect(result!.source).toBe("provider-fallback")
})
test("returns first matching entry even if later entries have better matches", () => {
@@ -427,8 +427,8 @@ describe("resolveModelWithFallback", () => {
})
// #then
expect(result.model).toBe("openai/gpt-5.2")
expect(result.source).toBe("provider-fallback")
expect(result!.model).toBe("openai/gpt-5.2")
expect(result!.source).toBe("provider-fallback")
})
test("falls through to system default when none match availability", () => {
@@ -447,8 +447,8 @@ describe("resolveModelWithFallback", () => {
})
// #then
expect(result.model).toBe("system/default")
expect(result.source).toBe("system-default")
expect(result!.model).toBe("system/default")
expect(result!.source).toBe("system-default")
})
})
@@ -462,11 +462,81 @@ describe("resolveModelWithFallback", () => {
}
// #when
const result: ModelResolutionResult = resolveModelWithFallback(input)
const result = resolveModelWithFallback(input)
// #then
expect(typeof result.model).toBe("string")
expect(["override", "provider-fallback", "system-default"]).toContain(result.source)
expect(result).toBeDefined()
expect(typeof result!.model).toBe("string")
expect(["override", "provider-fallback", "system-default"]).toContain(result!.source)
})
})
describe("Optional systemDefaultModel", () => {
test("returns undefined when systemDefaultModel is undefined and no fallback found", () => {
// #given
const input: ExtendedModelResolutionInput = {
fallbackChain: [
{ providers: ["anthropic"], model: "nonexistent-model" },
],
availableModels: new Set(["openai/gpt-5.2"]),
systemDefaultModel: undefined,
}
// #when
const result = resolveModelWithFallback(input)
// #then
expect(result).toBeUndefined()
})
test("returns undefined when no fallbackChain and systemDefaultModel is undefined", () => {
// #given
const input: ExtendedModelResolutionInput = {
availableModels: new Set(["openai/gpt-5.2"]),
systemDefaultModel: undefined,
}
// #when
const result = resolveModelWithFallback(input)
// #then
expect(result).toBeUndefined()
})
test("still returns override when userModel provided even if systemDefaultModel undefined", () => {
// #given
const input: ExtendedModelResolutionInput = {
userModel: "anthropic/claude-opus-4-5",
availableModels: new Set(),
systemDefaultModel: undefined,
}
// #when
const result = resolveModelWithFallback(input)
// #then
expect(result).toBeDefined()
expect(result!.model).toBe("anthropic/claude-opus-4-5")
expect(result!.source).toBe("override")
})
test("still returns fallback match when systemDefaultModel undefined", () => {
// #given
const input: ExtendedModelResolutionInput = {
fallbackChain: [
{ providers: ["anthropic"], model: "claude-opus-4-5" },
],
availableModels: new Set(["anthropic/claude-opus-4-5"]),
systemDefaultModel: undefined,
}
// #when
const result = resolveModelWithFallback(input)
// #then
expect(result).toBeDefined()
expect(result!.model).toBe("anthropic/claude-opus-4-5")
expect(result!.source).toBe("provider-fallback")
})
})
})
+10 -5
View File
@@ -6,7 +6,7 @@ import { readConnectedProvidersCache } from "./connected-providers-cache"
export type ModelResolutionInput = {
userModel?: string
inheritedModel?: string
systemDefault: string
systemDefault?: string
}
export type ModelSource =
@@ -24,7 +24,7 @@ export type ExtendedModelResolutionInput = {
userModel?: string
fallbackChain?: FallbackEntry[]
availableModels: Set<string>
systemDefaultModel: string
systemDefaultModel?: string
}
function normalizeModel(model?: string): string | undefined {
@@ -32,7 +32,7 @@ function normalizeModel(model?: string): string | undefined {
return trimmed || undefined
}
export function resolveModel(input: ModelResolutionInput): string {
export function resolveModel(input: ModelResolutionInput): string | undefined {
return (
normalizeModel(input.userModel) ??
normalizeModel(input.inheritedModel) ??
@@ -42,7 +42,7 @@ export function resolveModel(input: ModelResolutionInput): string {
export function resolveModelWithFallback(
input: ExtendedModelResolutionInput,
): ModelResolutionResult {
): ModelResolutionResult | undefined {
const { userModel, fallbackChain, availableModels, systemDefaultModel } = input
// Step 1: Override
@@ -92,7 +92,12 @@ export function resolveModelWithFallback(
log("No available model found in fallback chain, falling through to system default")
}
// Step 4: System default
// Step 3: System default (if provided)
if (systemDefaultModel === undefined) {
log("No model resolved - systemDefaultModel not configured")
return undefined
}
log("Model resolved via system default", { model: systemDefaultModel })
return { model: systemDefaultModel, source: "system-default" }
}