fix(cli): include fallback_models when writing agent model config (#3144)

Installer now generates fallback_models array alongside explicit model
overrides, preserving the fallback chain.

46 tests pass, tsc clean.

Closes #3144
This commit is contained in:
YeonGyu-Kim
2026-04-07 15:24:00 +09:00
parent e8c8376db4
commit bb1bad8e02
3 changed files with 218 additions and 11 deletions
@@ -915,6 +915,14 @@ exports[`generateModelConfig fallback providers uses OpenCode Zen models when on
"model": "opencode/claude-sonnet-4-6",
},
"explore": {
"fallback_models": [
{
"model": "opencode/minimax-m2.7",
},
{
"model": "opencode/gpt-5-nano",
},
],
"model": "opencode/claude-haiku-4-5",
},
"hephaestus": {
@@ -1132,6 +1140,14 @@ exports[`generateModelConfig fallback providers uses OpenCode Zen models with is
"model": "opencode/claude-sonnet-4-6",
},
"explore": {
"fallback_models": [
{
"model": "opencode/minimax-m2.7",
},
{
"model": "opencode/gpt-5-nano",
},
],
"model": "opencode/claude-haiku-4-5",
},
"hephaestus": {
@@ -1353,6 +1369,11 @@ exports[`generateModelConfig fallback providers uses GitHub Copilot models when
"model": "github-copilot/claude-sonnet-4.6",
},
"explore": {
"fallback_models": [
{
"model": "github-copilot/grok-code-fast-1",
},
],
"model": "github-copilot/gpt-5-mini",
},
"hephaestus": {
@@ -1534,6 +1555,11 @@ exports[`generateModelConfig fallback providers uses GitHub Copilot models with
"model": "github-copilot/claude-sonnet-4.6",
},
"explore": {
"fallback_models": [
{
"model": "github-copilot/grok-code-fast-1",
},
],
"model": "github-copilot/gpt-5-mini",
},
"hephaestus": {
@@ -1842,6 +1868,17 @@ exports[`generateModelConfig mixed provider scenarios uses Claude + OpenCode Zen
"model": "anthropic/claude-sonnet-4-6",
},
"explore": {
"fallback_models": [
{
"model": "opencode/minimax-m2.7",
},
{
"model": "opencode/claude-haiku-4-5",
},
{
"model": "opencode/gpt-5-nano",
},
],
"model": "anthropic/claude-haiku-4-5",
},
"hephaestus": {
@@ -2114,6 +2151,11 @@ exports[`generateModelConfig mixed provider scenarios uses OpenAI + Copilot comb
"model": "github-copilot/claude-sonnet-4.6",
},
"explore": {
"fallback_models": [
{
"model": "github-copilot/grok-code-fast-1",
},
],
"model": "github-copilot/gpt-5-mini",
},
"hephaestus": {
@@ -2353,6 +2395,11 @@ exports[`generateModelConfig mixed provider scenarios uses Claude + ZAI combinat
"model": "anthropic/claude-haiku-4-5",
},
"librarian": {
"fallback_models": [
{
"model": "anthropic/claude-haiku-4-5",
},
],
"model": "zai-coding-plan/glm-4.7",
},
"metis": {
@@ -2573,6 +2620,17 @@ exports[`generateModelConfig mixed provider scenarios uses all fallback provider
"model": "github-copilot/claude-sonnet-4.6",
},
"explore": {
"fallback_models": [
{
"model": "github-copilot/grok-code-fast-1",
},
{
"model": "opencode/minimax-m2.7",
},
{
"model": "opencode/gpt-5-nano",
},
],
"model": "opencode/claude-haiku-4-5",
},
"hephaestus": {
@@ -2586,6 +2644,17 @@ exports[`generateModelConfig mixed provider scenarios uses all fallback provider
"variant": "medium",
},
"librarian": {
"fallback_models": [
{
"model": "opencode/minimax-m2.7-highspeed",
},
{
"model": "opencode/claude-haiku-4-5",
},
{
"model": "opencode/gpt-5-nano",
},
],
"model": "zai-coding-plan/glm-4.7",
},
"metis": {
@@ -2949,6 +3018,20 @@ exports[`generateModelConfig mixed provider scenarios uses all providers togethe
"model": "anthropic/claude-sonnet-4-6",
},
"explore": {
"fallback_models": [
{
"model": "github-copilot/grok-code-fast-1",
},
{
"model": "opencode/minimax-m2.7",
},
{
"model": "opencode/claude-haiku-4-5",
},
{
"model": "opencode/gpt-5-nano",
},
],
"model": "anthropic/claude-haiku-4-5",
},
"hephaestus": {
@@ -2966,6 +3049,20 @@ exports[`generateModelConfig mixed provider scenarios uses all providers togethe
"variant": "medium",
},
"librarian": {
"fallback_models": [
{
"model": "opencode/minimax-m2.7-highspeed",
},
{
"model": "anthropic/claude-haiku-4-5",
},
{
"model": "opencode/claude-haiku-4-5",
},
{
"model": "opencode/gpt-5-nano",
},
],
"model": "zai-coding-plan/glm-4.7",
},
"metis": {
@@ -3472,6 +3569,20 @@ exports[`generateModelConfig mixed provider scenarios uses all providers with is
"model": "anthropic/claude-sonnet-4-6",
},
"explore": {
"fallback_models": [
{
"model": "github-copilot/grok-code-fast-1",
},
{
"model": "opencode/minimax-m2.7",
},
{
"model": "opencode/claude-haiku-4-5",
},
{
"model": "opencode/gpt-5-nano",
},
],
"model": "anthropic/claude-haiku-4-5",
},
"hephaestus": {
@@ -3489,6 +3600,20 @@ exports[`generateModelConfig mixed provider scenarios uses all providers with is
"variant": "medium",
},
"librarian": {
"fallback_models": [
{
"model": "opencode/minimax-m2.7-highspeed",
},
{
"model": "anthropic/claude-haiku-4-5",
},
{
"model": "opencode/claude-haiku-4-5",
},
{
"model": "opencode/gpt-5-nano",
},
],
"model": "zai-coding-plan/glm-4.7",
},
"metis": {
+52
View File
@@ -549,6 +549,58 @@ describe("generateModelConfig", () => {
})
})
describe("special-case agents include fallback_models", () => {
test("explore includes fallback_models when Copilot and Claude are both available", () => {
// #given both Copilot and Claude are available
const config = createConfig({ hasCopilot: true, hasClaude: true })
// #when generateModelConfig is called
const result = generateModelConfig(config)
// #then explore should have fallback_models from the remaining chain entries
expect(result.agents?.explore?.model).toBe("anthropic/claude-haiku-4-5")
expect(result.agents?.explore?.fallback_models).toBeDefined()
expect(result.agents?.explore?.fallback_models?.length).toBeGreaterThan(0)
})
test("explore omits fallback_models when only one provider matches chain entries", () => {
// #given only Claude is available
const config = createConfig({ hasClaude: true })
// #when generateModelConfig is called
const result = generateModelConfig(config)
// #then explore should not have fallback_models (only one chain entry matches)
expect(result.agents?.explore?.model).toBe("anthropic/claude-haiku-4-5")
expect(result.agents?.explore?.fallback_models).toBeUndefined()
})
test("librarian includes fallback_models when opencode-go and Claude are both available", () => {
// #given opencode-go and Claude are available
const config = createConfig({ hasOpencodeGo: true, hasClaude: true })
// #when generateModelConfig is called
const result = generateModelConfig(config)
// #then librarian should have fallback_models
expect(result.agents?.librarian?.model).toBe("opencode-go/minimax-m2.7")
expect(result.agents?.librarian?.fallback_models).toBeDefined()
expect(result.agents?.librarian?.fallback_models?.length).toBeGreaterThan(0)
})
test("librarian omits fallback_models when only one provider matches", () => {
// #given only opencode-go is available
const config = createConfig({ hasOpencodeGo: true })
// #when generateModelConfig is called
const result = generateModelConfig(config)
// #then librarian should not have fallback_models
expect(result.agents?.librarian?.model).toBe("opencode-go/minimax-m2.7")
expect(result.agents?.librarian?.fallback_models).toBeUndefined()
})
})
describe("schema URL", () => {
test("always includes correct schema URL", () => {
// #given any config
+41 -11
View File
@@ -37,22 +37,29 @@ function toFallbackModelObject(entry: FallbackEntry, provider: string): Fallback
}
}
function attachFallbackModels<T extends AgentConfig | CategoryConfig>(
config: T,
function collectAvailableFallbacks(
fallbackChain: FallbackEntry[],
availability: ReturnType<typeof toProviderAvailability>,
): T {
): FallbackModelObject[] {
const expandedFallbacks = fallbackChain.flatMap((entry) =>
entry.providers
.filter((provider) => isProviderAvailable(provider, availability))
.map((provider) => toFallbackModelObject(entry, provider))
)
const uniqueFallbacks = expandedFallbacks.filter((entry, index, allEntries) =>
return expandedFallbacks.filter((entry, index, allEntries) =>
allEntries.findIndex((candidate) =>
candidate.model === entry.model &&
candidate.variant === entry.variant
) === index
)
}
function attachFallbackModels<T extends AgentConfig | CategoryConfig>(
config: T,
fallbackChain: FallbackEntry[],
availability: ReturnType<typeof toProviderAvailability>,
): T {
const uniqueFallbacks = collectAvailableFallbacks(fallbackChain, availability)
const primaryIndex = uniqueFallbacks.findIndex((entry) => entry.model === config.model)
if (primaryIndex === -1) {
return config
@@ -69,6 +76,23 @@ function attachFallbackModels<T extends AgentConfig | CategoryConfig>(
}
}
function attachAllFallbackModels<T extends AgentConfig | CategoryConfig>(
config: T,
fallbackChain: FallbackEntry[],
availability: ReturnType<typeof toProviderAvailability>,
): T {
const uniqueFallbacks = collectAvailableFallbacks(fallbackChain, availability)
const fallbackModels = uniqueFallbacks.filter((entry) => entry.model !== config.model)
if (fallbackModels.length === 0) {
return config
}
return {
...config,
fallback_models: fallbackModels,
}
}
export function generateModelConfig(config: InstallConfig): GeneratedOmoConfig {
@@ -101,26 +125,32 @@ export function generateModelConfig(config: InstallConfig): GeneratedOmoConfig {
for (const [role, req] of Object.entries(CLI_AGENT_MODEL_REQUIREMENTS)) {
if (role === "librarian") {
let agentConfig: AgentConfig | undefined
if (avail.opencodeGo) {
agents[role] = { model: "opencode-go/minimax-m2.7" }
agentConfig = { model: "opencode-go/minimax-m2.7" }
} else if (avail.zai) {
agents[role] = { model: ZAI_MODEL }
agentConfig = { model: ZAI_MODEL }
}
if (agentConfig) {
agents[role] = attachAllFallbackModels(agentConfig, req.fallbackChain, avail)
}
continue
}
if (role === "explore") {
let agentConfig: AgentConfig
if (avail.native.claude) {
agents[role] = { model: "anthropic/claude-haiku-4-5" }
agentConfig = { model: "anthropic/claude-haiku-4-5" }
} else if (avail.opencodeZen) {
agents[role] = { model: "opencode/claude-haiku-4-5" }
agentConfig = { model: "opencode/claude-haiku-4-5" }
} else if (avail.opencodeGo) {
agents[role] = { model: "opencode-go/minimax-m2.7" }
agentConfig = { model: "opencode-go/minimax-m2.7" }
} else if (avail.copilot) {
agents[role] = { model: "github-copilot/gpt-5-mini" }
agentConfig = { model: "github-copilot/gpt-5-mini" }
} else {
agents[role] = { model: "opencode/gpt-5-nano" }
agentConfig = { model: "opencode/gpt-5-nano" }
}
agents[role] = attachAllFallbackModels(agentConfig, req.fallbackChain, avail)
continue
}